Treat intentional cancellation as a non-failure terminal
This commit is contained in:
@@ -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)"),
|
||||
}
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -125,6 +125,7 @@ impl SegmentLogSink {
|
||||
| LogEntry::Invoke { .. }
|
||||
| LogEntry::RunYielded { .. }
|
||||
| LogEntry::RunResumed { .. }
|
||||
| LogEntry::RunCancelled { .. }
|
||||
| LogEntry::RunErrored { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ impl SessionCapture {
|
||||
SessionSnapshotEntryData::SystemItem { .. }
|
||||
| SessionSnapshotEntryData::RunYielded { .. }
|
||||
| SessionSnapshotEntryData::RunResumed { .. }
|
||||
| SessionSnapshotEntryData::RunCancelled
|
||||
| SessionSnapshotEntryData::RunError { .. } => return None,
|
||||
};
|
||||
Some(CapturedHistoryEntry {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user