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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 15:28:03 -06:00
1 parent ded6d0774e
commit 070365404e
2 files changed
+51 -10

No files matched your search

@@ -113,13 +113,14 @@ conversation, only when all of these are true:
- The session is in one of these two states: - The session is in one of these two states:
- **Suspended.** It has no active turn, pending runtime request, undelivered - **Suspended.** It has no active turn, pending runtime request, undelivered
provider event, or unconfirmed provider exit. provider event, or unconfirmed provider exit.
- **An interrupted turn of the same heartbeat run.** The deploy restarted the - **An interrupted turn or open session of the same heartbeat run.** The
runner mid-turn and the same run resumes. The turn has no accepted semantic deploy restarted the runner and the same run resumes. There is no accepted
result, no pending runtime request, no unconfirmed provider exit, and no semantic result, no pending runtime request, no unconfirmed provider exit,
active goal. These are the preconditions of the same-build lost-turn and no active goal. Same-build recovery then runs unchanged. It reopens an
recovery, which then runs unchanged: it restores the conversation once the open session. For an active turn it uses lost-turn recovery: the
original provider lifetime loses its fence quorum, and otherwise closes the conversation is restored once the original provider lifetime loses its
lost turn. An active turn saved by a different run stays strict. 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 startup has no candidate admission.
- The saved descriptor names this startup's sidecar command and arguments. - The saved descriptor names this startup's sidecar command and arguments.
@@ -1056,7 +1056,8 @@ impl AcpxCommandExecutor {
} }
// A deploy that restarts the runner mid-turn resumes the SAME heartbeat // 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 // through `restore_conversation_after_lost_turn`; an upgraded build must
// take the same path instead of refusing the digest. Re-bind only when // take the same path instead of refusing the digest. Re-bind only when
// that recovery's own preconditions hold, so its provider-lifetime fence // 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. // closed. A different run's active turn stays strict.
fn rebinds_interrupted_turn(&self, state: &AcpxDurableState) -> bool { fn rebinds_interrupted_turn(&self, state: &AcpxDurableState) -> bool {
state.descriptor.run_id == self.context.run_id state.descriptor.run_id == self.context.run_id
&& matches!(state.lifecycle.as_str(), "turn_starting" | "turn_active") && match state.lifecycle.as_str() {
&& state.active_turn_id.is_some() "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.semantic_result.is_none()
&& (state.goal_projection.is_null() || goal_is_dormant(&state.goal_projection)) && (state.goal_projection.is_null() || goal_is_dormant(&state.goal_projection))
} }
@@ -6218,6 +6224,40 @@ mod tests {
fs::remove_dir_all(directory).unwrap(); 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)] #[cfg(unix)]
#[test] #[test]
fn interrupted_turn_after_upgrade_stays_strict_when_lost_turn_recovery_is_unsafe() { fn interrupted_turn_after_upgrade_stays_strict_when_lost_turn_recovery_is_unsafe() {