fix(runner): resume an ACPX turn interrupted by a deploy instead of refusing the launch digest

A deploy that restarts the runner mid-turn resumes the same heartbeat run,
but the saved ACPX state carries the previous build's launch profile digest,
so restore failed with provider_state_incompatible and the task needed Retry
(Claude in the K14 upgrade-mid-run wave). Settled sessions already re-bind.
Re-bind an interrupted turn of the same run too, under exactly the
preconditions of the same-build lost-turn recovery, which then decides
whether to restore the conversation or close the lost turn. A different
run's active turn and unsafe states stay strict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-08 15:27:07 -06:00
1 parent 776f4cb602
commit ded6d0774e
2 files changed
+122 -6

No files matched your search

@@ -110,8 +110,16 @@ digest, so restore cannot tell which part changed.
Restore re-binds a saved session to the current launch profile, and keeps the
conversation, only when all of these are true:
- The session is suspended. It has no active turn, pending runtime request,
undelivered provider event, or unconfirmed provider exit.
- 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.
- The startup has no candidate admission.
- The saved descriptor names this startup's sidecar command and arguments.
@@ -1046,12 +1046,28 @@ impl AcpxCommandExecutor {
profile.candidate_admission.is_none()
&& state.descriptor.sidecar_command == profile.command
&& state.descriptor.sidecar_args == profile.args
}) && state.lifecycle == "suspended"
&& state.identity.is_some()
&& state.active_turn_id.is_none()
}) && state.identity.is_some()
&& !state.provider_exit_unconfirmed
&& state.pending_events.is_empty()
&& state.pending_runtime_requests.is_empty()
&& ((state.lifecycle == "suspended"
&& state.active_turn_id.is_none()
&& state.pending_events.is_empty())
|| self.rebinds_interrupted_turn(state))
}
// 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
// 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
// check still decides whether the conversation is restored or the turn is
// 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()
&& state.semantic_result.is_none()
&& (state.goal_projection.is_null() || goal_is_dormant(&state.goal_projection))
}
fn restore(&mut self) -> Result<(), DurableRunnerError> {
@@ -6150,6 +6166,98 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
/// The same heartbeat run resumes after a deploy restarted its runner
/// mid-turn (K14 upgrade mid-run). The old build saved an interrupted turn
/// under its launch profile digest.
fn interrupted_turn_after_runner_upgrade(
label: &str,
) -> (PathBuf, DurableRunnerConfig, PathBuf) {
let (directory, mut config, _, saved) = settled_session_after_runner_upgrade(label);
config.run_id = "run-1".to_owned();
config.turn_id = "turn-1".to_owned();
config.item_id = "item-1".to_owned();
config.validate().unwrap();
let mut state: AcpxDurableState = serde_json::from_slice(&saved).unwrap();
state.lifecycle = "turn_active".into();
state.active_turn_id = Some("turn-1".into());
let state_path = directory.join(ACPX_PROVIDER_STATE_FILE);
fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
(directory, config, state_path)
}
#[cfg(unix)]
#[test]
fn interrupted_turn_of_the_same_run_rebinds_to_the_upgraded_runner_launch_profile() {
let (directory, config, state_path) =
interrupted_turn_after_runner_upgrade("upgrade-lost-turn");
let prior: AcpxDurableState =
serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
let current_digest = config
.acpx_launch_profile
.as_ref()
.unwrap()
.canonical_digest()
.unwrap();
assert_ne!(prior.launch_profile_digest, current_digest);
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
recovered
.restore()
.expect("an interrupted turn saved by the previous build takes the same lost-turn recovery as a same-build restart");
let restored: AcpxDurableState =
serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
assert_eq!(restored.launch_profile_digest, current_digest);
assert_eq!(restored.identity, prior.identity);
assert_eq!(
restored.descriptor.command_digest,
prior.descriptor.command_digest
);
// The fixture's provider cannot start, so recovery takes the ordinary
// close path of a lost turn instead of failing the restore.
assert!(restored.active_turn_id.is_none());
fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn interrupted_turn_after_upgrade_stays_strict_when_lost_turn_recovery_is_unsafe() {
let guards: [(&str, fn(&mut AcpxDurableState)); 4] = [
("provider_exit_unconfirmed", |state| {
state.provider_exit_unconfirmed = true
}),
("accepted_semantic_result", |state| {
state.semantic_result = Some(json!({"reportedWorkDisposition": "done"}))
}),
("unanswered_runtime_request", |state| {
state
.pending_runtime_requests
.insert("input-1".to_owned(), pending_input_request("input-1"));
}),
("active_goal", |state| {
state.goal_projection = json!({"goal": {"status": "active"}})
}),
];
for (label, seed) in guards {
let (directory, config, state_path) =
interrupted_turn_after_runner_upgrade("upgrade-lost-turn-strict");
let mut state: AcpxDurableState =
serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap();
seed(&mut state);
fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
let retained = fs::read(&state_path).unwrap();
let mut recovered = AcpxCommandExecutor::with_runner_config(&directory, &config);
let error = recovered.restore().unwrap_err();
assert_eq!(
error.classification(),
Some(crate::durable::PROVIDER_STATE_INCOMPATIBLE),
"{label}: {error}"
);
assert!(recovered.session.is_none(), "{label}");
assert_eq!(fs::read(&state_path).unwrap(), retained, "{label}");
fs::remove_dir_all(directory).unwrap();
}
}
#[cfg(unix)]
#[test]
fn unrestorable_saved_sessions_keep_strict_bindings_and_name_their_class() {