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:
- **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.
@@ -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() {