Treat intentional cancellation as a non-failure terminal

This commit is contained in:
2026-10-01 17:04:05 +09:00
parent 0ec2b3ad86
commit 64ed50f6c0
17 changed files with 256 additions and 15 deletions
+1
View File
@@ -66,6 +66,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
WorkerRunResult::Finished => println!("(finished)"),
WorkerRunResult::Paused => println!("(paused)"),
WorkerRunResult::LimitReached => println!("(turn limit reached)"),
WorkerRunResult::Cancelled => println!("(cancelled)"),
WorkerRunResult::Interrupted { message, .. } => println!("(interrupted: {message})"),
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
}
+6
View File
@@ -2794,6 +2794,12 @@ where
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
WorkerRunResult::Cancelled if pause_requested => {
(WorkerStatus::Paused, RunResult::Paused)
}
WorkerRunResult::Cancelled => {
(WorkerStatus::Idle, RunResult::Cancelled)
}
WorkerRunResult::Interrupted { .. } if pause_requested => {
let _ = working_event_tx.send(Event::RunEnd { result: RunResult::Paused });
return (WorkerStatus::Paused, shutdown_requested, false);
@@ -632,7 +632,7 @@ fn extraction_disposition(
ExtractionDisposition::Failed(error.source.to_string())
}
Ok(run) => match &run.lifecycle {
WorkerRunResult::RolledBack => {
WorkerRunResult::Cancelled | WorkerRunResult::RolledBack => {
ExtractionDisposition::Cancelled("memory-extract cancelled".to_string())
}
WorkerRunResult::Interrupted { message, .. } => {
+5
View File
@@ -213,6 +213,7 @@ where
let outcome = match worker.run_text(&input).await {
Ok(lifecycle @ WorkerRunResult::Finished)
| Ok(lifecycle @ WorkerRunResult::Paused)
| Ok(lifecycle @ WorkerRunResult::Cancelled)
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
@@ -333,6 +334,10 @@ fn classify_internal_turn_result(
Ok(WorkerRunResult::Interrupted { message, .. }) => {
(InternalWorkerSessionStatus::Stopped, Some(message))
}
Ok(WorkerRunResult::Cancelled) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled".to_string()),
),
Ok(WorkerRunResult::RolledBack) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled before AI output".to_string()),
@@ -64,6 +64,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunResumed { .. }
| LogEntry::RunCancelled { .. }
| LogEntry::RunErrored { .. }) => {
let mut projected =
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
+1
View File
@@ -125,6 +125,7 @@ impl SegmentLogSink {
| LogEntry::Invoke { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunResumed { .. }
| LogEntry::RunCancelled { .. }
| LogEntry::RunErrored { .. }
)
}
+1
View File
@@ -287,6 +287,7 @@ impl SessionCapture {
SessionSnapshotEntryData::SystemItem { .. }
| SessionSnapshotEntryData::RunYielded { .. }
| SessionSnapshotEntryData::RunResumed { .. }
| SessionSnapshotEntryData::RunCancelled
| SessionSnapshotEntryData::RunError { .. } => return None,
};
Some(CapturedHistoryEntry {
+57 -2
View File
@@ -4974,6 +4974,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.last_run_interrupted = false;
Ok(WorkerRunResult::LimitReached)
}
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => {
self.last_run_interrupted = false;
Ok(WorkerRunResult::Cancelled)
}
EngineRunExit::Interrupted(reason) => {
self.last_run_interrupted = true;
Ok(WorkerRunResult::Interrupted {
@@ -5446,6 +5450,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
active_run_turn_count,
})?;
}
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => {
self.commit_entry(LogEntry::RunCancelled {
ts: segment_log::now_millis(),
})?;
}
EngineRunExit::Interrupted(reason) => {
self.commit_entry(LogEntry::RunErrored {
ts: segment_log::now_millis(),
@@ -7376,6 +7385,8 @@ pub enum WorkerRunResult {
Paused,
/// The worker reached its configured max_turns limit.
LimitReached,
/// The logical Run was intentionally cancelled after observable work.
Cancelled,
/// The run was interrupted by a known or unexpected terminal cause.
Interrupted { code: ErrorCode, message: String },
/// The submit-time user turn was rolled back because no AI output was materialized.
@@ -9335,6 +9346,48 @@ mod build_summary_prompt_tests {
assert_eq!(worker.engine().active_run_turn_count(), None);
}
#[tokio::test]
async fn intentional_cancel_commits_a_non_failure_terminal() {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let mut worker = Worker::new(
minimal_manifest(),
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
store.clone(),
WorkerWorkspaceContext::no_workspace(),
WorkerFilesystemAuthority::None,
Scope::empty(),
)
.await
.unwrap();
worker.ensure_segment_head().await.unwrap();
worker.engine_mut().set_active_run_turn_count(Some(1));
let result = worker
.handle_worker_result(
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled),
0,
)
.await
.unwrap();
assert_eq!(result, WorkerRunResult::Cancelled);
let entries = store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
assert!(
entries
.iter()
.any(|entry| matches!(entry, LogEntry::RunCancelled { .. }))
);
assert!(
!entries
.iter()
.any(|entry| matches!(entry, LogEntry::RunErrored { .. }))
);
assert!(!worker.last_run_interrupted);
}
#[tokio::test]
async fn fresh_run_clears_interrupted_budget_before_pre_run_compaction() {
let dir = tempfile::tempdir().unwrap();
@@ -10522,7 +10575,7 @@ mod build_summary_prompt_tests {
}
#[tokio::test]
async fn interrupted_result_terminalizes_orphan_before_run_completed() {
async fn cancelled_result_terminalizes_orphan_before_run_terminal() {
let dir = tempfile::tempdir().unwrap();
let manifest = minimal_manifest();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
@@ -10631,7 +10684,9 @@ mod build_summary_prompt_tests {
.position(|entry| {
matches!(
entry,
LogEntry::RunCompleted { .. } | LogEntry::RunErrored { .. }
LogEntry::RunCompleted { .. }
| LogEntry::RunCancelled { .. }
| LogEntry::RunErrored { .. }
)
})
.expect("durable final run status");
+65
View File
@@ -3693,6 +3693,71 @@ async fn empty_turn_rollback_removes_only_the_most_recent_turn() {
);
}
#[tokio::test]
async fn cancel_after_assistant_output_is_a_non_failure_terminal() {
let client = MockClient::sequential(vec![MockResponse::Hang(vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "committed before cancel"),
LlmEvent::text_block_stop(0, None),
])]);
let worker = make_worker(client).await;
let handle = spawn_controller(worker).await;
let mut rx = handle.subscribe();
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"cancel this active turn",
))
.await
.unwrap();
assert!(
drain_until(
&mut rx,
std::time::Duration::from_secs(2),
|event| matches!(event, Event::TextDone { .. })
)
.await,
"assistant output should be durable before cancellation"
);
handle
.send(Method::Cancel {
command: worker_command(&handle),
})
.await
.unwrap();
assert!(
drain_until(
&mut rx,
std::time::Duration::from_secs(2),
|event| matches!(
event,
Event::RunEnd {
result: protocol::RunResult::Cancelled
}
)
)
.await,
"intentional cancellation must emit RunEnd::Cancelled, not Event::Error"
);
wait_for_status(&handle, WorkerStatus::Idle).await;
let (entries, _) = handle.sink.subscribe_with_snapshot();
assert!(
entries
.iter()
.any(|entry| matches!(entry, LogEntry::RunCancelled { .. })),
"cancelled run must keep a durable non-failure terminal: {entries:?}"
);
assert!(
!entries
.iter()
.any(|entry| matches!(entry, LogEntry::RunErrored { .. })),
"intentional cancellation must not be restored as a run error: {entries:?}"
);
}
#[tokio::test]
async fn pause_after_assistant_token_does_not_rollback() {
let client = MockClient::sequential(vec![MockResponse::Hang(vec![