From 070365404ebd0f307821db7d1c4f880e9bae09a1 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Nov 2026 15:28:03 -0600 Subject: [PATCH] fix(runner): also re-bind a same-run open ACPX session after a deploy A same-build restart restores an open session between turns, which is also the state a lost-turn restore leaves behind, so a later upgrade restart can find it. Co-Authored-By: Claude Opus 5.5 --- .../paperclip-runner/docs/durable-recovery.md | 15 +++--- .../runner-core/src/acpx_provider_backend.rs | 46 +++++++++++++++++-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index 69fe684df30..d84d61343e9 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -113,13 +113,14 @@ conversation, only when all of these are true: - The session is in one of these two states: - **Suspended.** It has no active turn, pending runtime request, undelivered provider event, or unconfirmed provider exit. - - **An interrupted turn of the same heartbeat run.** The deploy restarted the - runner mid-turn and the same run resumes. The turn has no accepted semantic - result, no pending runtime request, no unconfirmed provider exit, and no - active goal. These are the preconditions of the same-build lost-turn - recovery, which then runs unchanged: it restores the conversation once the - original provider lifetime loses its fence quorum, and otherwise closes the - lost turn. An active turn saved by a different run stays strict. + - **An interrupted turn or open session of the same heartbeat run.** The + deploy restarted the runner and the same run resumes. There is no accepted + semantic result, no pending runtime request, no unconfirmed provider exit, + and no active goal. Same-build recovery then runs unchanged. It reopens an + open session. For an active turn it uses lost-turn recovery: the + conversation is restored once the original provider lifetime loses its + fence quorum, and otherwise the lost turn is closed. An active turn or open + session saved by a different run stays strict. - The startup has no candidate admission. - The saved descriptor names this startup's sidecar command and arguments. 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 ae3ec6cb5c6..687f24960b4 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 @@ -1056,7 +1056,8 @@ impl AcpxCommandExecutor { } // A deploy that restarts the runner mid-turn resumes the SAME heartbeat - // run with a saved active turn. A same-build restart recovers that turn + // run with a saved active turn or open session. A same-build restart + // reopens an open session and recovers an active turn // through `restore_conversation_after_lost_turn`; an upgraded build must // take the same path instead of refusing the digest. Re-bind only when // that recovery's own preconditions hold, so its provider-lifetime fence @@ -1064,8 +1065,13 @@ impl AcpxCommandExecutor { // closed. A different run's active turn stays strict. fn rebinds_interrupted_turn(&self, state: &AcpxDurableState) -> bool { state.descriptor.run_id == self.context.run_id - && matches!(state.lifecycle.as_str(), "turn_starting" | "turn_active") - && state.active_turn_id.is_some() + && match state.lifecycle.as_str() { + "turn_starting" | "turn_active" => state.active_turn_id.is_some(), + // A same-build restart restores an open session between turns + // (also the state a lost-turn restore leaves behind). + "session_open" => state.active_turn_id.is_none(), + _ => false, + } && state.semantic_result.is_none() && (state.goal_projection.is_null() || goal_is_dormant(&state.goal_projection)) } @@ -6218,6 +6224,40 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[cfg(unix)] + #[test] + fn open_session_of_the_same_run_rebinds_to_the_upgraded_runner_launch_profile() { + let (directory, config, state_path) = + interrupted_turn_after_runner_upgrade("upgrade-open-session"); + let mut state: AcpxDurableState = + serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + // A lost-turn restore leaves the session open with undelivered events. + state.lifecycle = "session_open".into(); + state.active_turn_id = None; + state + .push(NormalizedProviderEvent { + event_type: "session.resumed".into(), + priority: EventPriority::P0, + payload: json!({}), + }) + .unwrap(); + fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap(); + + let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config); + // The fixture's provider cannot start; the restore must get that far + // instead of refusing the saved state. + let error = recovered.restore().unwrap_err(); + assert!( + error.to_string().contains("failed to start ACPX provider"), + "{error}" + ); + assert_ne!( + error.classification(), + Some(crate::durable::PROVIDER_STATE_INCOMPATIBLE) + ); + fs::remove_dir_all(directory).unwrap(); + } + #[cfg(unix)] #[test] fn interrupted_turn_after_upgrade_stays_strict_when_lost_turn_recovery_is_unsafe() {