mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
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() {
|
||||
|
||||
Reference in new issue
Block a user