diff --git a/packages/paperclip-runner/runner/crates/runner-core/examples/recovery-fixture.rs b/packages/paperclip-runner/runner/crates/runner-core/examples/recovery-fixture.rs index 7d32ed22622..54b545c40a4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/examples/recovery-fixture.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/examples/recovery-fixture.rs @@ -273,6 +273,7 @@ fn main() -> Result<(), Box> { value("--reconnect-grace-ms")?.parse()?, )), max_runtime: Duration::ZERO, + turn_scoped: false, }; run_durable_runner( config, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index be646761028..da20602953d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -3341,6 +3341,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(60), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs index df70d461c93..1241f1c5843 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/paperclip-runnerd.rs @@ -243,6 +243,13 @@ fn optional_value(args: &[String], name: &str) -> Result, LocalRu .ok_or_else(|| LocalRunnerError::invalid(format!("missing value for {name}"))) } +/// Launches without `--lifecycle-mode` (older controllers) and warm launches +/// keep the turn-end provider restart; only an explicit per-turn lifecycle, +/// whose Runner exits with its run, skips it. +fn turn_scoped(args: &[String]) -> Result { + Ok(optional_value(args, "--lifecycle-mode")?.as_deref() == Some("per_turn")) +} + fn optional_u64(args: &[String], name: &str) -> Result, LocalRunnerError> { let Some(index) = args.iter().position(|argument| argument == name) else { return Ok(None); @@ -497,6 +504,7 @@ fn run_durable(args: &[String]) -> Result<(), LocalRunnerError> { reconnect_delay: duration("--reconnect-delay-ms", 250)?, reconnect_grace: optional_u64(args, "--reconnect-grace-ms")?.map(Duration::from_millis), max_runtime: duration("--max-runtime-ms", 0)?, + turn_scoped: turn_scoped(args)?, }; if host_control.is_some() && !config.connect_url.starts_with("listen://") { return Err(LocalRunnerError::invalid( @@ -696,6 +704,26 @@ fn run_main(args: Vec) -> ExitCode { mod tests { use super::*; + #[test] + fn only_a_per_turn_lifecycle_is_turn_scoped() { + let args = |values: &[&str]| { + values + .iter() + .map(|value| value.to_string()) + .collect::>() + }; + assert!(turn_scoped(&args(&["--lifecycle-mode", "per_turn"])).unwrap()); + assert!(!turn_scoped(&args(&[ + "--lifecycle-mode", + "warm", + "--idle-timeout-ms", + "1" + ])) + .unwrap()); + assert!(!turn_scoped(&args(&[])).unwrap()); + assert!(turn_scoped(&args(&["--lifecycle-mode"])).is_err()); + } + #[test] fn cursor_cloud_launch_flags_are_optional_as_a_whole_and_required_as_an_exact_pin_set() { assert!(cursor_cloud_launch_profile(&[]).unwrap().is_none()); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs index 14b4077918b..b58e953a2a4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs @@ -249,6 +249,10 @@ pub struct DurableRunnerConfig { pub reconnect_grace: Option, /// Zero disables the total process lifetime limit. pub max_runtime: Duration, + /// The controller launched this Runner for one run (`--lifecycle-mode + /// per_turn`): the process exits with its run, so nothing reuses a provider + /// generation prepared after its turn settles. + pub turn_scoped: bool, } impl DurableRunnerConfig { @@ -429,6 +433,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(60), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs index 2386f4218d7..5188d9e8012 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/runner.rs @@ -3053,6 +3053,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(1), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 82df0080416..e2765c5bb08 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -2859,6 +2859,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(1), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs index 569297dcbfc..ed60da24385 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/transport.rs @@ -1783,6 +1783,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(1), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/external_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/external_provider_backend.rs index fe0d64ce6f8..789fa40fc7e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/external_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/external_provider_backend.rs @@ -1084,6 +1084,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(60), + turn_scoped: false, } } fn command(kind: &str, payload: Value) -> Command { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/managed_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/managed_provider_backend.rs index 8614ef99ad1..29543fb17f5 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/managed_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/managed_provider_backend.rs @@ -2617,6 +2617,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(60), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs index d906e5ffa03..5ce46394a8b 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/native_provider_backend.rs @@ -345,6 +345,12 @@ impl CommandExecutor for NativeProviderCommandExecutor { } fn prepare_next_turn(&mut self) -> Result<(), DurableRunnerError> { + // A per-turn Runner exits with its run. Restarting the provider after + // its turn would only spawn a generation for teardown; a later turn of + // the same run still restarts lazily at its start. + if self.config.turn_scoped { + return Ok(()); + } match self.selected.as_mut() { Some(SelectedExecutor::Acpx(executor)) => executor.prepare_next_turn(), _ => Ok(()), diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs b/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs index 5b05061290c..9bc82b0bd35 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/runtime_notifications.rs @@ -412,6 +412,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::ZERO, + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/task_memory.rs b/packages/paperclip-runner/runner/crates/runner-core/src/task_memory.rs index 5279f4f3fef..6bdefbd7dda 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/task_memory.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/task_memory.rs @@ -268,6 +268,7 @@ mod tests { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::ZERO, + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs index f9d9a3072ca..bd049c55cab 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_preparation.rs @@ -60,6 +60,10 @@ struct Harness { impl Harness { fn new() -> Self { + Self::configured(|_| {}) + } + + fn configured(adjust: impl FnOnce(&mut DurableRunnerConfig)) -> Self { let catalog: Value = serde_json::from_str(include_str!("../../../../acpx-profiles.json")).unwrap(); let profile = &catalog["profiles"]["claude"]; @@ -84,7 +88,7 @@ impl Harness { .unwrap(); fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o700)).unwrap(); let args = vec!["--profile-digest".to_owned(), digest.to_owned()]; - let config = DurableRunnerConfig { + let mut config = DurableRunnerConfig { connect_url: "ws://127.0.0.1/runner".to_owned(), ca_bundle_path: None, state_dir: fixture.0.clone(), @@ -114,6 +118,7 @@ impl Harness { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(30), + turn_scoped: false, }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", @@ -131,6 +136,7 @@ impl Harness { "mcp": {"assignmentSetId": "assignment-1", "digest": "e".repeat(64), "bindingId": "binding-run-1"}, }, }); + adjust(&mut config); let mut executor = NativeProviderCommandExecutor::with_runner_config(&fixture.0, &config); executor .execute(&command(1, "run.prepare", json!({"provider": descriptor}))) diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs index f1994f3b21a..7bb49534589 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_park_readiness.rs @@ -85,6 +85,7 @@ fn fixture( reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(30), + turn_scoped: false, }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs index f710d2f4bfe..bb8cbb6946a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs @@ -79,6 +79,7 @@ fn checkpoints_the_sidecar_and_rebinds_consecutive_warm_runs_before_accepting_wo reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(30), + turn_scoped: false, }; let mut descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", @@ -238,6 +239,7 @@ fn reopens_the_conversation_for_a_turn_after_a_suspension_stop() { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(30), + turn_scoped: false, }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", @@ -355,6 +357,7 @@ fn restores_the_conversation_after_losing_an_active_turn_to_a_runner_restart() { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(30), + turn_scoped: false, }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", @@ -497,6 +500,7 @@ fn restores_a_turn_lost_to_a_runner_upgrade_under_the_new_launch_profile() { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(30), + turn_scoped: false, }; let descriptor = json!({ "kind": "acpx", "provider": "acpx", "driver": "acpx_runtime", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index cde9beac64d..2d6733572e4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -594,6 +594,7 @@ fn durable_config(directory: &Path) -> DurableRunnerConfig { reconnect_delay: std::time::Duration::from_millis(1), reconnect_grace: None, max_runtime: std::time::Duration::from_secs(5), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs index 88380e45ba8..1ff0cd61168 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/durable_recovery.rs @@ -32,6 +32,7 @@ fn config(state_dir: PathBuf) -> DurableRunnerConfig { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(1), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index bf9c2ffc267..d6ccb98ee10 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -152,6 +152,7 @@ fn config(state_dir: &Path) -> DurableRunnerConfig { reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::from_secs(60), + turn_scoped: false, } } diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs index 04505dca4a1..6165bb59c06 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/runtime_notifications.rs @@ -56,6 +56,7 @@ fn real_cli_uses_relative_state_from_another_directory_and_never_process_run_ide reconnect_delay: Duration::from_millis(1), reconnect_grace: None, max_runtime: Duration::ZERO, + turn_scoped: false, }; let store = DurableStateStore::new(&path).unwrap(); let (mut state, _) = store.load_or_create(&config).unwrap(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/support/acpx_turn_end_restart.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/support/acpx_turn_end_restart.rs index 55a7a11f534..6671c6b3106 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/support/acpx_turn_end_restart.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/support/acpx_turn_end_restart.rs @@ -484,3 +484,26 @@ fn a_runner_restart_in_the_same_run_keeps_the_failed_turn_end_restart_closed() { harness.complete_turn("next-run"); harness.executor.shutdown().unwrap(); } + +#[test] +fn a_per_turn_runner_skips_the_turn_end_restart_and_restarts_a_later_turn_at_its_start() { + let mut harness = Harness::configured(|config| config.turn_scoped = true); + let old_pid = harness.opened["processId"].clone(); + harness.complete_turn("first"); + let before = harness.fixture.journal().len(); + // The Runner exits with its run: no generation is spawned for teardown. + harness.executor.prepare_next_turn().unwrap(); + assert_eq!(harness.fixture.process_starts().len(), 1); + assert_eq!( + commands_after(&harness.fixture.journal(), before), + Vec::::new() + ); + assert_eq!(live_providers(&harness), [old_pid.clone()]); + // A later turn of the same run still gets a fresh generation, at its start. + let (events, commands, _) = start_follow_up(&mut harness, "second"); + assert_eq!(commands[..2], ["session.suspend", "initialize"]); + assert_eq!(reconciled(&events)["previousProcessId"], old_pid); + assert_eq!(harness.fixture.process_starts().len(), 2); + harness.executor.shutdown().unwrap(); + assert_eq!(live_providers(&harness), Vec::::new()); +}