fix(runner): refuse to reopen a session whose run failed its turn-end restart

open_session did not check the saved turn-end restart failure, so
session.open or a same-run run.attach reopened the provider and later
turns ran. Refuse there while the marker names the current run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-09 09:47:45 -06:00
1 parent f5d5d35d90
commit 7fe9345030
2 files changed
+31 -1

No files matched your search

@@ -1788,6 +1788,17 @@ impl AcpxCommandExecutor {
"ACPX provider lifetime cleanup is not yet proven", "ACPX provider lifetime cleanup is not yet proven",
)); ));
} }
// No path (session.open, a same-run run.attach) reopens a session whose
// run failed closed at its turn-end provider restart.
if self.session.is_none()
&& self.state.as_ref().is_some_and(|state| {
state.turn_end_restart_failed_run.as_deref() == Some(self.context.run_id.as_str())
})
{
return Err(DurableRunnerError::invalid(
"ACPX session failed closed after this run's turn-end provider restart failed",
));
}
if self.session.is_none() { if self.session.is_none() {
let recovering = self let recovering = self
.state .state
@@ -441,11 +441,30 @@ fn a_runner_restart_in_the_same_run_keeps_the_failed_turn_end_restart_closed() {
.to_string(); .to_string();
assert_eq!(failed, "ACPX session is not open"); assert_eq!(failed, "ACPX session is not open");
assert_eq!(harness.fixture.process_starts().len(), starts); assert_eq!(harness.fixture.process_starts().len(), starts);
// Neither session.open nor a same-run attach reopens it.
let reopened = harness
.executor
.execute(&command(91, "session.open", json!({})))
.unwrap_err()
.to_string();
assert!(reopened.contains("failed closed"), "{reopened}");
let same_run_attach = json!({"provider": harness.descriptor.clone()});
let attach_error = harness
.executor
.execute(&command(92, "run.attach", same_run_attach))
.unwrap_err()
.to_string();
assert!(attach_error.contains("failed closed"), "{attach_error}");
assert_eq!(harness.fixture.process_starts().len(), starts);
assert_eq!(
harness.fixture.state()["turnEndRestartFailedRun"],
harness.config.run_id
);
assert_eq!(live_providers(&harness), Vec::<Value>::new()); assert_eq!(live_providers(&harness), Vec::<Value>::new());
assert_eq!(harness.fixture.state()["lifecycle"], "suspended"); assert_eq!(harness.fixture.state()["lifecycle"], "suspended");
// The next run still resumes the conversation and clears the restriction. // The next run still resumes the conversation and clears the restriction.
harness.executor.shutdown().unwrap(); harness.executor.shutdown().unwrap();
harness.sequence = 90; harness.sequence = 92;
harness.config.run_id = "run-2".to_owned(); harness.config.run_id = "run-2".to_owned();
harness.executor = harness.executor =
NativeProviderCommandExecutor::with_runner_config(&harness.fixture.0, &harness.config); NativeProviderCommandExecutor::with_runner_config(&harness.fixture.0, &harness.config);