diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index ec0bcbf..02dda6f 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "276fb9d6bd1d5a8d5f38df37ccf104664f6f5043", - "release_name": "dryrun-276fb9d6bd1d", + "source_commit": "cb99b1e5f0baa07af19211bd164241b5776f91fe", + "release_name": "dryrun-cb99b1e5f0ba", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 8613898..a80fb44 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -6097,6 +6097,28 @@ mod tests { .large_input_policy .supported_handle_types .contains(&"SourceSnapshot".to_owned())); + assert!( + inspection + .metadata + .restart_compatibility + .source_edits_can_restart_from_clean_task_boundary + ); + assert!( + inspection + .metadata + .restart_compatibility + .requires_clean_checkpoint_boundary + ); + assert_eq!( + inspection.metadata.restart_compatibility.compares_task_abi, + inspection.metadata.task_metadata.task_abi + ); + assert!( + inspection + .metadata + .restart_compatibility + .incompatible_changes_require_whole_process_restart + ); assert!(!inspection.metadata.embeds_full_container_images); assert!(inspection .metadata @@ -6239,6 +6261,91 @@ mod tests { assert_ne!(first.metadata.identity, second.metadata.identity); } + #[test] + fn bundle_rebuild_after_source_edit_keeps_restart_compatibility_contract() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { println!(\"first\"); }\n", + ) + .unwrap(); + + let inspect = |project: &Path| { + bundle_inspection( + BundleInspectArgs { + project: Some(project.to_path_buf()), + source_provider: None, + disabled_source_providers: Vec::new(), + json: true, + }, + PathBuf::from("/unused"), + ) + .unwrap() + }; + + let first = inspect(temp.path()); + fs::write( + temp.path().join("src/main.rs"), + "fn main() { println!(\"second\"); }\n", + ) + .unwrap(); + let rebuilt = inspect(temp.path()); + + assert_ne!(first.metadata.identity, rebuilt.metadata.identity); + assert_ne!( + first + .metadata + .source_metadata + .selected_inputs + .iter() + .find(|input| input.path == "src/main.rs") + .unwrap() + .digest, + rebuilt + .metadata + .source_metadata + .selected_inputs + .iter() + .find(|input| input.path == "src/main.rs") + .unwrap() + .digest + ); + assert_eq!( + first.metadata.task_metadata.task_abi, + rebuilt.metadata.task_metadata.task_abi + ); + assert_eq!( + first.metadata.restart_compatibility.compares_task_abi, + rebuilt.metadata.restart_compatibility.compares_task_abi + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .source_edits_can_restart_from_clean_task_boundary + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .requires_clean_checkpoint_boundary + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .discards_unflushed_task_local_changes + ); + assert!( + rebuilt + .metadata + .restart_compatibility + .incompatible_changes_require_whole_process_restart + ); + } + #[test] fn source_provider_manifest_digest_does_not_include_local_project_path() { let first = tempfile::tempdir().unwrap(); @@ -7712,6 +7819,25 @@ mod tests { .iter() .any(|handle| handle == "Artifact") ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"] + ["source_edits_can_restart_from_clean_task_boundary"], + true + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"] + ["requires_clean_checkpoint_boundary"], + true + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"]["compares_task_abi"], + report["bundle"]["metadata"]["task_metadata"]["task_abi"] + ); + assert_eq!( + report["bundle"]["metadata"]["restart_compatibility"] + ["incompatible_changes_require_whole_process_restart"], + true + ); assert_eq!(report["status"], "built"); assert_eq!(report["scheduled_work"], false); } diff --git a/crates/disasmer-core/src/bundle.rs b/crates/disasmer-core/src/bundle.rs index d40e6de..2587046 100644 --- a/crates/disasmer-core/src/bundle.rs +++ b/crates/disasmer-core/src/bundle.rs @@ -28,6 +28,7 @@ pub struct BundleMetadata { pub source_metadata: BundleSourceMetadata, pub debug_metadata: BundleDebugMetadata, pub large_input_policy: BundleLargeInputPolicy, + pub restart_compatibility: BundleRestartCompatibility, pub environments: Vec, pub selected_inputs: Vec, pub embeds_full_container_images: bool, @@ -65,6 +66,17 @@ pub struct BundleLargeInputPolicy { pub supported_handle_types: Vec, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleRestartCompatibility { + pub source_edits_can_restart_from_clean_task_boundary: bool, + pub requires_clean_checkpoint_boundary: bool, + pub compares_task_abi: Digest, + pub compares_environment_digests: bool, + pub compares_serialized_args: bool, + pub discards_unflushed_task_local_changes: bool, + pub incompatible_changes_require_whole_process_restart: bool, +} + impl BundleIdentityInputs { pub fn identity(&self) -> Digest { let mut parts = vec![ @@ -136,6 +148,15 @@ impl BundleIdentityInputs { "VFS".to_owned(), ], }, + restart_compatibility: BundleRestartCompatibility { + source_edits_can_restart_from_clean_task_boundary: true, + requires_clean_checkpoint_boundary: true, + compares_task_abi: self.task_abi.clone(), + compares_environment_digests: true, + compares_serialized_args: true, + discards_unflushed_task_local_changes: true, + incompatible_changes_require_whole_process_restart: true, + }, environments: self.environments.clone(), selected_inputs: self.selected_inputs.clone(), embeds_full_container_images: false, @@ -227,6 +248,25 @@ mod tests { .large_input_policy .supported_handle_types .contains(&"Artifact".to_owned())); + assert!( + metadata + .restart_compatibility + .source_edits_can_restart_from_clean_task_boundary + ); + assert!( + metadata + .restart_compatibility + .requires_clean_checkpoint_boundary + ); + assert_eq!( + metadata.restart_compatibility.compares_task_abi, + inputs.task_abi + ); + assert!( + metadata + .restart_compatibility + .incompatible_changes_require_whole_process_restart + ); assert_eq!(metadata.environments.len(), 1); assert!(!metadata.embeds_full_container_images); } diff --git a/crates/disasmer-core/src/lib.rs b/crates/disasmer-core/src/lib.rs index 64f3a3d..fcc67bf 100644 --- a/crates/disasmer-core/src/lib.rs +++ b/crates/disasmer-core/src/lib.rs @@ -28,7 +28,7 @@ pub use auth::{ }; pub use bundle::{ BundleDebugMetadata, BundleIdentityInputs, BundleLargeInputPolicy, BundleMetadata, - BundleSourceMetadata, BundleTaskMetadata, SelectedInput, + BundleRestartCompatibility, BundleSourceMetadata, BundleTaskMetadata, SelectedInput, }; pub use capability::{Capability, CapabilityReportError, EnvironmentBackend, NodeCapabilities, Os}; pub use checkpoint::{ diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index db26bb6..1cd544e 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -207,6 +207,7 @@ for (const [name, pattern] of [ ["process control report coverage", /fn process_restart_and_cancel_reports_expose_control_boundaries\(\)/], ["task restart report coverage", /fn task_restart_reports_clean_boundary_requirements\(\)/], ["build no full repo upload coverage", /fn build_command_reuses_bundle_inspection_without_full_repo_upload\(\)/], + ["bundle rebuild restart compatibility coverage", /fn bundle_rebuild_after_source_edit_keeps_restart_compatibility_contract\(\)/], ["inspect missing environment coverage", /fn bundle_inspect_reports_missing_environment_references_before_schedule\(\)/], ["inspect source provider override coverage", /fn bundle_inspect_reports_source_provider_overrides_before_schedule\(\)/], ["build missing environment gate coverage", /fn build_blocks_before_schedule_on_missing_environment_reference\(\)/], @@ -396,6 +397,11 @@ expect( "CLI build coverage verifies large input handle posture", /large_input_policy[\s\S]*selected_inputs_are_content_digests[\s\S]*selected_input_bytes_included[\s\S]*silent_task_argument_serialization[\s\S]*supported_handle_types/ ); +expect( + cli, + "CLI bundle coverage verifies restart compatibility metadata", + /restart_compatibility[\s\S]*source_edits_can_restart_from_clean_task_boundary[\s\S]*requires_clean_checkpoint_boundary[\s\S]*compares_task_abi[\s\S]*incompatible_changes_require_whole_process_restart/ +); expect( cli, "CLI blocks build before scheduling unsafe inputs", @@ -436,6 +442,7 @@ for (const [name, pattern] of [ ["doctor reachability JSON mode", /doctorJson\.coordinator_reachability\.status/], ["bundle inspect JSON mode", /\["bundle", "inspect", "--project", project, "--json"\]/], ["bundle inspect large input JSON mode", /large_input_policy[\s\S]*SourceSnapshot/], + ["bundle inspect restart compatibility JSON mode", /restart_compatibility[\s\S]*source_edits_can_restart_from_clean_task_boundary/], ["auth expiry posture", /token_expiry_posture[\s\S]*expires_at/], ]) { expect(outputModeSmoke, name, pattern); diff --git a/scripts/cli-output-mode-smoke.js b/scripts/cli-output-mode-smoke.js index a34bf4b..4a601de 100644 --- a/scripts/cli-output-mode-smoke.js +++ b/scripts/cli-output-mode-smoke.js @@ -112,5 +112,21 @@ assert.strictEqual( false ); assert(inspectJson.metadata.large_input_policy.supported_handle_types.includes("SourceSnapshot")); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.source_edits_can_restart_from_clean_task_boundary, + true +); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.requires_clean_checkpoint_boundary, + true +); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.compares_task_abi, + inspectJson.metadata.task_metadata.task_abi +); +assert.strictEqual( + inspectJson.metadata.restart_compatibility.incompatible_changes_require_whole_process_restart, + true +); console.log("CLI output mode smoke passed");