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
@@ -1075,6 +1075,17 @@ fn project_session_snapshot_for_segment(
},
));
}
LogEntry::RunCancelled { ts } => {
active_run_turn_count = None;
entries.push(legacy_entry(
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunCancelled,
));
}
LogEntry::RunErrored {
ts,
message,
@@ -2849,6 +2860,27 @@ mod tests {
));
}
#[test]
fn cancelled_run_projects_as_a_non_failure_terminal() {
let snapshot = project_session_snapshot(
crate::new_session_id(),
&[
LogEntry::Invoke {
ts: 10,
trigger: protocol::InvokeKind::UserSend,
},
LogEntry::RunCancelled { ts: 20 },
],
);
assert_eq!(snapshot.entries.len(), 1);
assert_eq!(snapshot.entries[0].timestamp, 20);
assert!(matches!(
snapshot.entries[0].data,
SessionSnapshotEntryData::RunCancelled
));
}
#[test]
fn retained_snapshot_read_projects_active_segment_without_rewriting_files() {
let root = tempfile::tempdir().unwrap();
+32 -4
View File
@@ -28,7 +28,7 @@ use crate::logged_item::LoggedItem;
/// `run()`/`resume()` (current callers persist a single TurnEnd at
/// run completion); the fork-point seq for `at_turn_index` is the
/// preceding `Invoke` entry, not the TurnEnd.
/// - `RunCompleted` / `RunErrored` — marks end of a `run()` or `resume()` call
/// - `RunCompleted` / `RunCancelled` / `RunErrored` — terminal run outcomes
/// - `PausedTurnAbandoned` — explicit abandon/cancel of a paused interrupted turn
/// - `ConfigChanged` — `RequestConfig` mutation
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -86,9 +86,10 @@ pub enum LogEntry {
/// serde tag already occupies `"kind"`.
///
/// Replay marks the run interrupted until a terminal `RunCompleted`,
/// `RunErrored`, or `PausedTurnAbandoned` entry proves how it ended. This
/// makes a process/disk failure between Invoke and its terminal record
/// restore conservatively instead of re-running a dangling tool call.
/// `RunCancelled`, `RunErrored`, or `PausedTurnAbandoned` entry proves how
/// it ended. This makes a process/disk failure between Invoke and its
/// terminal record restore conservatively instead of re-running a dangling
/// tool call.
Invoke { ts: u64, trigger: InvokeKind },
/// Canonical user submission with its exact model-visible entries. Typed
@@ -152,6 +153,10 @@ pub enum LogEntry {
active_run_turn_count: usize,
},
/// The logical Run ended because the user intentionally cancelled it.
/// This is terminal, but is not an execution failure.
RunCancelled { ts: u64 },
/// `run()` / `resume()` が `EngineError` で終了した。
/// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。
/// Audit-only metadata: replay は `interrupted` のみ反映する。
@@ -389,6 +394,10 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.last_run_interrupted = true;
state.active_run_turn_count = Some(*active_run_turn_count);
}
LogEntry::RunCancelled { .. } => {
state.last_run_interrupted = false;
state.active_run_turn_count = None;
}
LogEntry::RunErrored { interrupted, .. } => {
state.last_run_interrupted = *interrupted;
state.active_run_turn_count = None;
@@ -889,6 +898,25 @@ mod tests {
assert_eq!(state.active_run_turn_count, Some(2));
}
#[test]
fn replay_cancelled_run_is_terminal_without_becoming_a_failure() {
let state = collect_state(&[
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 1,
},
LogEntry::RunCancelled { ts: 300 },
]);
assert!(!state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, None);
assert_eq!(state.turn_count, 1);
}
#[test]
fn legacy_yielded_run_completed_remains_resumable_but_is_not_terminal_success() {
let entry: LogEntry = serde_json::from_value(serde_json::json!({