octl_core/cancel.rs
1//! Single-lock run cancellation.
2//!
3//! `run cancel` does three things under **one** held [`RunLock`]: refuse a run
4//! that is already in a non-cancelled terminal state, synthesize a terminal
5//! `node.report` for every still-live node, and append `run.status: cancelled`
6//! once. Holding one lock for the whole operation serializes it against other
7//! *cooperating* writers (those that honor the lock) so the node reads and the
8//! node-report appends can't interleave — which is what made the pre-refactor
9//! CLI loop both racy and prone to over-reporting `cancelled_nodes` (it pushed
10//! a node id even when the per-node append landed after another process had
11//! already settled the node, so the reducer dropped it). Under one lock the
12//! node we read is the node we cancel, so the reported count is honest.
13//!
14//! This is **not crash-atomic**: each `append_and_apply_unlocked` is its own
15//! durable append, so a crash or I/O error partway through can leave some nodes
16//! cancelled and `run.status` not yet appended. Recovery is convergent — a
17//! re-`cancel` of an already-`Cancelled` run scans the still-live stragglers
18//! and finishes the job — not transactional rollback.
19//!
20//! Two consistency properties beyond the single lock:
21//!
22//! - **Enumeration *and per-node liveness* are from the event log, not the
23//! projection directory.** The node set and each node's current status are
24//! both replayed from `events.jsonl` (the source of truth) in one streaming
25//! pass rather than scanned from `nodes/*.json`. A `node.created` can be
26//! appended+fsynced while its projection write is crash-interrupted
27//! (`events.rs` documents the log leading the projections); a `nodes/` scan
28//! would silently drop that node, mark the run `cancelled`, and let a future
29//! `rebuild_projections` resurrect it as live under a `Cancelled` run. Walking
30//! the log closes that window. Crucially, replaying `node.status` / `node.report`
31//! to derive each node's status (rather than trusting `read_node_opt`) closes a
32//! second window: a *non-cancel* terminal event (e.g. a `node.report`
33//! `success: true`) fsynced but not yet folded leaves a stale-live projection,
34//! and a projection-derived liveness check would over-write that node with a
35//! fresh cancel that diverges on rebuild. The log-derived status settles it as
36//! already-terminal instead — the log wins. (The manifest's `node_count` is
37//! *also* a projection written in the same interrupted fold, so it is no more
38//! authoritative than `nodes/` — and it carries no node ids — which is why we
39//! replay the log rather than trust the counter.)
40//!
41//! - **Each synthesized event carries a deterministic idempotency key**
42//! (`run-cancel:<run_id>:node:<node_id>` and `run-cancel:<run_id>:run-status`).
43//! If a crash lands an append+fsync but interrupts the projection fold, the
44//! node/run still reads non-terminal, so a re-`cancel` would append a *second*
45//! logical-cancel event (duplicating it for auditors, metrics, and rebuild).
46//! The prior cancel events (scoped by `(kind, key)` for this run) are captured
47//! in the same replay pass, so instead of re-appending, the loop **re-folds
48//! the already-logged event** via [`apply_event`](crate::reducer) — converging a projection
49//! the crash left non-terminal *without* a duplicate log line (a re-fold is a
50//! clean no-op when the projection already agrees). The whole transaction is
51//! then both non-duplicating and projection-convergent.
52//!
53//! The cancel ledger is built by a *streaming* pass: [`for_each_event_probe`](crate::events)
54//! walks `events.jsonl` line by line, parsing only the small envelope + status
55//! fields each line needs and materializing a full [`Event`] payload solely for
56//! the handful of lines in this run's `run-cancel:<run_id>:` key namespace (the
57//! events the re-fold path replays). The whole log is never held in memory, so
58//! lock-hold time and peak memory stay bounded even for a run with hundreds of
59//! nodes and multi-KB `node.report` payloads.
60//!
61//! What is still *not* derived from the log here: **run-level** liveness (the
62//! terminal-refusal check and `run_was_already_cancelled`) is read from the
63//! manifest projection, with the prior-cancel re-fold converging a crash-stranded
64//! `run.status`. Deriving the run status from the log too would conflate a
65//! crash-stranded `run.status: cancelled` (manifest stale, must re-fold and
66//! report a *fresh* cancel) with an already-folded one, since the log is
67//! identical in both cases — so the manifest read stays authoritative for the
68//! run-level decision, exactly as the per-node convergence path consults
69//! `read_node_opt` only to tell those two cases apart.
70
71use std::collections::HashMap;
72
73use serde::Deserialize;
74use serde_json::{json, Value};
75
76use crate::error::{Error, Result};
77use crate::events::{append_and_apply_unlocked, excerpt, for_each_event_probe};
78use crate::lock::{LockedRun, RunLock};
79use crate::paths::RunPaths;
80use crate::projections::{read_manifest, read_node_opt};
81use crate::reducer::apply_event;
82use crate::schema::{Event, NodeId, RunId, Status};
83
84/// Outcome of a [`cancel_run`] transaction. Lets a thin CLI wrapper report
85/// honestly what actually changed: which live nodes it converged, which were
86/// already settled (skipped, not double-reported), and whether the run itself
87/// was already cancelled (a convergence-only no-op rather than a fresh cancel).
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct CancelOutcome {
90 /// True when the run's manifest was already `Cancelled` on entry, so no
91 /// `run.status: cancelled` event was appended. The call still scans and
92 /// converges any straggler nodes (an interrupted earlier cancel), so this
93 /// is a SUCCESS, not an error: "no-op: run was already cancelled,
94 /// converged N additional nodes".
95 pub run_was_already_cancelled: bool,
96 /// Nodes this cancel transaction ensured are terminally cancelled: live
97 /// nodes for which it synthesized and durably appended a terminal cancel
98 /// `node.report` (and folded it), plus any node whose cancel `node.report` a
99 /// prior interrupted cancel had already durably appended (matched by
100 /// `(kind, idempotency_key)`) and which this call converged by *re-folding*
101 /// that event rather than re-appending. Either way the node carries a
102 /// terminal cancel in the source-of-truth log and its projection is folded
103 /// (or, for a still-missing projection, will fold on rebuild — see the
104 /// module docs); none is double-reported against a node that was already
105 /// terminal on entry.
106 pub nodes_cancelled: Vec<NodeId>,
107 /// Nodes whose *status* was already terminal on entry and so were skipped —
108 /// never double-reported as freshly cancelled.
109 pub nodes_already_terminal: Vec<NodeId>,
110}
111
112/// Cancel a run in a single locked transaction. Acquires the run's
113/// [`RunLock`] once for the whole operation, then delegates to
114/// [`cancel_run_unlocked`].
115///
116/// # Errors
117///
118/// - [`Error::RunAlreadyTerminal`] if the run is `Done`/`Failed` — refused
119/// without mutating state.
120/// - I/O / corrupt-log errors from reading the manifest, listing nodes, or
121/// appending events.
122pub fn cancel_run(paths: &RunPaths, note: Option<&str>) -> Result<CancelOutcome> {
123 RunLock::with_lock(paths, |lock| cancel_run_unlocked(lock, paths, note))
124}
125
126/// The locked body of [`cancel_run`]. The `lock: &LockedRun` witness proves the
127/// caller already holds the run's exclusive [`RunLock`]; this is the sanctioned
128/// lock-held composition path so the manifest read, the per-node
129/// read-then-append loop, and the final `run.status` append all share one
130/// critical section (it calls [`append_and_apply_unlocked`], never
131/// [`crate::append_and_apply_event`], which would deadlock by re-locking).
132pub fn cancel_run_unlocked(
133 lock: &LockedRun<'_>,
134 paths: &RunPaths,
135 note: Option<&str>,
136) -> Result<CancelOutcome> {
137 let started = std::time::Instant::now();
138 let manifest = read_manifest(paths)?;
139
140 // Refuse a non-cancelled terminal run BEFORE touching any node: cancelling
141 // a Done/Failed run would synthesize node reports and append a
142 // `run.status: cancelled` the reducer's terminal-state guard then drops,
143 // so the CLI would claim a transition that never happened. An already-
144 // `Cancelled` run is not refused — it falls through to converge stragglers.
145 if manifest.status.is_terminal() && manifest.status != Status::Cancelled {
146 return Err(Error::RunAlreadyTerminal {
147 status: manifest.status,
148 });
149 }
150 let run_was_already_cancelled = manifest.status == Status::Cancelled;
151
152 // Normalize the cancel reason ONCE up front. An empty or whitespace-only
153 // `--note` would otherwise flow into the synthesized report as `reason: ""`,
154 // which the reducer rejects (`CancelledRequiresReason`) — aborting the whole
155 // transaction mid-loop and, since retries reuse the same bad note, leaving
156 // the run permanently un-cancellable. A blank note falls back to the
157 // default.
158 let reason = note
159 .map(str::trim)
160 .filter(|s| !s.is_empty())
161 .unwrap_or("cancelled by user");
162
163 // One streaming replay pass over the source-of-truth log: the authoritative
164 // node set *and each node's current status* (both immune to the projection
165 // crash window), plus the prior cancel events already recorded (so a prior
166 // interrupted cancel isn't duplicated — it is re-folded instead).
167 let CancelLedger {
168 node_status,
169 prior_cancel,
170 } = read_cancel_ledger(paths)?;
171
172 let mut nodes_cancelled = Vec::new();
173 let mut nodes_already_terminal = Vec::new();
174
175 for (nid, log_status) in node_status {
176 let key = node_cancel_key(&paths.run_id, &nid);
177 // Convergence path first: this run's cancel already logged a
178 // `node.report` for this node (a prior, possibly crash-interrupted,
179 // cancel). The log is identical whether that report's projection fold
180 // landed or not, so `read_node_opt` is what tells the two apart — an
181 // already-folded terminal projection is a clean no-op reported as
182 // already-terminal, while a crash-stranded still-live projection is
183 // converged by re-folding the already-logged event (no duplicate append)
184 // and reported as cancelled. This is the only remaining projection read,
185 // and it serves convergence, not the liveness decision below.
186 if let Some(prior) = prior_cancel.get(&("node.report".to_owned(), key.clone())) {
187 if let Some(n) = read_node_opt(paths, &nid)? {
188 if n.status.is_terminal() {
189 nodes_already_terminal.push(nid);
190 continue;
191 }
192 }
193 apply_event(paths, prior)?;
194 nodes_cancelled.push(nid);
195 continue;
196 }
197 // No prior cancel for this node: the event log is authoritative for
198 // liveness. A node the log replays as terminal — a non-cancel terminal
199 // (`node.report success` / a `node.status` to a terminal value), or a
200 // cancel logged outside this run's key namespace — is already settled
201 // and skipped, even if a stale projection still reads live (the window
202 // cancel-liveness-from-log closes: the log wins). Only a node the log
203 // shows non-terminal (including a `node.created` whose projection write
204 // was interrupted — the crash window a `nodes/*.json` scan would drop)
205 // gets a synthesized terminal cancel report so the log records it as
206 // cancelled and a future rebuild can't resurrect it as live.
207 if log_status.is_terminal() {
208 nodes_already_terminal.push(nid);
209 continue;
210 }
211 let data = json!({
212 "success": false,
213 "cancelled": true,
214 "reason": reason,
215 "summary": "Run cancelled before agent reported.",
216 "discussion_items": [],
217 "spinoff_proposals": [],
218 "wrap_up_recommendations": []
219 });
220 append_and_apply_unlocked(lock, paths, "node.report", Some(&nid), Some(&key), data)?;
221 nodes_cancelled.push(nid);
222 }
223
224 if !run_was_already_cancelled {
225 let key = run_status_cancel_key(&paths.run_id);
226 if let Some(prior) = prior_cancel.get(&("run.status".to_owned(), key.clone())) {
227 // A prior interrupted cancel already logged the terminal `run.status`
228 // (fsynced before its manifest fold). Re-fold it to converge the
229 // manifest instead of appending a duplicate `run.status: cancelled`.
230 apply_event(paths, prior)?;
231 } else {
232 let mut status_data = serde_json::Map::new();
233 status_data.insert("status".into(), "cancelled".into());
234 // Record the operator note only when one was actually supplied (the
235 // trimmed, non-blank value); a blank `--note` leaves the field unset
236 // rather than writing an empty string.
237 if let Some(n) = note.map(str::trim).filter(|s| !s.is_empty()) {
238 status_data.insert("note".into(), n.into());
239 }
240 append_and_apply_unlocked(
241 lock,
242 paths,
243 "run.status",
244 None,
245 Some(&key),
246 serde_json::Value::Object(status_data),
247 )?;
248 }
249 }
250
251 tracing::debug!(
252 target: "octl_core::cancel",
253 run_id = %paths.run_id,
254 held_ms = started.elapsed().as_millis() as u64,
255 nodes_cancelled = nodes_cancelled.len(),
256 nodes_already_terminal = nodes_already_terminal.len(),
257 "cancel transaction complete",
258 );
259
260 Ok(CancelOutcome {
261 run_was_already_cancelled,
262 nodes_cancelled,
263 nodes_already_terminal,
264 })
265}
266
267/// Cancel-relevant facts replayed from `events.jsonl` in one streaming pass
268/// under the held lock.
269struct CancelLedger {
270 /// Every node a `node.created` event introduced, paired with the status the
271 /// log replays for it, deduped and sorted by numeric suffix. The
272 /// authoritative live-node set *and* per-node liveness: replayed from the
273 /// source of truth, so it includes a node whose projection write was
274 /// crash-interrupted (the node a `nodes/*.json` scan would miss) and reports
275 /// a node terminal whenever the log says so even if the projection still
276 /// reads live (the window [`crate::events`] documents the log leading the
277 /// projections through).
278 node_status: Vec<(NodeId, Status)>,
279 /// Cancel events this run already logged, keyed by `(kind, idempotency_key)`
280 /// and limited to this run's `run-cancel:<run_id>:` key namespace (first
281 /// occurrence wins, mirroring [`crate::events::find_prior_with_key`]). The
282 /// cancel loop looks an entry up by its deterministic key to (a) avoid
283 /// re-appending a duplicate and (b) re-fold the event so a crash-stranded
284 /// projection converges. Keying by `(kind, key)` — not the bare string —
285 /// keeps a coincidental or forged key on an unrelated `kind` from masking a
286 /// real cancel append. Only these few lines have their full [`Event`] payload
287 /// materialized; every other line is skimmed envelope-only.
288 prior_cancel: HashMap<(String, String), Event>,
289}
290
291/// Envelope + the few small `data` fields the cancel ledger needs from each
292/// line, skimmed by [`for_each_event_probe`] without materializing the
293/// (potentially multi-KB) full `data` payload. serde ignores every other field,
294/// so a rich `node.report` is scanned but never allocated.
295#[derive(Deserialize)]
296struct CancelProbe {
297 kind: String,
298 #[serde(default)]
299 node_id: Option<NodeId>,
300 #[serde(default)]
301 idempotency_key: Option<String>,
302 #[serde(default)]
303 data: CancelProbeData,
304}
305
306/// The status-bearing `data` fields of `node.status` / `node.report`. All
307/// optional: any other event kind simply leaves them `None`.
308#[derive(Deserialize, Default)]
309struct CancelProbeData {
310 #[serde(default)]
311 status: Option<String>,
312 #[serde(default)]
313 success: Option<bool>,
314 #[serde(default)]
315 cancelled: Option<bool>,
316}
317
318/// Replay `events.jsonl` once, streaming, to build the [`CancelLedger`].
319///
320/// Reads through [`RunPaths::checked_events`] so a symlinked event log is
321/// refused, matching the mutation path — the cancel decision must not be made
322/// from content redirected outside the run tree. Uses [`for_each_event_probe`],
323/// which shares the crate's torn-tail policy: a crash-truncated final line is
324/// dropped as an uncommitted partial write, while any *interior* unparseable
325/// line is surfaced as [`Error::CorruptEventLog`] — so a corrupt log fails the
326/// cancel loudly rather than silently dropping a node. A missing log yields an
327/// empty ledger (run never appended an event — nothing to cancel).
328///
329/// Per-node status is accumulated by a tiny per-node state machine that mirrors
330/// the reducer's terminal-state guard: `node.created` seeds [`Status::Pending`]
331/// (idempotent on replay), and `node.status` / `node.report` transition a node
332/// only while it is still non-terminal. A `node.status` / `node.report` for a
333/// node id never introduced by a `node.created` is ignored, exactly as the
334/// reducer no-ops a status/report against a non-existent node. Malformed status
335/// fields degrade gracefully (the node is left non-terminal, hence cancelled)
336/// rather than aborting the whole cancel — the append path already validates
337/// every committed event, so this only matters for a hand-corrupted log.
338///
339/// Node ids are sorted by numeric suffix (not lexically), so output and the
340/// cancel order stay intuitive past the digit-width boundary where `n-10000`
341/// would otherwise sort before `n-9999` (see [`NodeId`]).
342fn read_cancel_ledger(paths: &RunPaths) -> Result<CancelLedger> {
343 let events_path = paths.checked_events()?;
344 let prefix = format!("run-cancel:{}:", paths.run_id.as_str());
345 // Creation order is preserved here and re-sorted by numeric suffix below.
346 let mut order: Vec<NodeId> = Vec::new();
347 let mut status: HashMap<NodeId, Status> = HashMap::new();
348 let mut prior_cancel: HashMap<(String, String), Event> = HashMap::new();
349
350 for_each_event_probe::<CancelProbe, _>(&events_path, |probe, raw| {
351 match probe.kind.as_str() {
352 "node.created" => {
353 if let Some(nid) = &probe.node_id {
354 // Idempotent on replay: a second `node.created` for the same
355 // id is a no-op, mirroring the reducer's existence guard.
356 if !status.contains_key(nid) {
357 order.push(nid.clone());
358 status.insert(nid.clone(), Status::Pending);
359 }
360 }
361 }
362 "node.status" => {
363 if let Some(nid) = &probe.node_id {
364 if let Some(cur) = status.get_mut(nid) {
365 if !cur.is_terminal() {
366 if let Some(ns) = probe.data.status.as_deref().and_then(parse_status) {
367 *cur = ns;
368 }
369 }
370 }
371 }
372 }
373 "node.report" => {
374 if let Some(nid) = &probe.node_id {
375 if let Some(cur) = status.get_mut(nid) {
376 // Terminal guard *before* deriving the outcome, mirroring
377 // the reducer: a report against an already-terminal node
378 // is a dead event (its payload may even be a bare `{}`).
379 if !cur.is_terminal() {
380 if let Some(ns) =
381 report_terminal_status(probe.data.success, probe.data.cancelled)
382 {
383 *cur = ns;
384 }
385 }
386 }
387 }
388 }
389 _ => {}
390 }
391 // Capture only this run's cancel events, keyed by (kind, key), and only
392 // for those materialize the full payload the re-fold path needs.
393 if let Some(key) = probe
394 .idempotency_key
395 .as_deref()
396 .filter(|k| k.starts_with(&prefix))
397 {
398 let entry = (probe.kind.clone(), key.to_owned());
399 if let std::collections::hash_map::Entry::Vacant(slot) = prior_cancel.entry(entry) {
400 let ev: Event =
401 serde_json::from_slice(raw).map_err(|e| Error::CorruptEventLog {
402 path: events_path.clone(),
403 reason: format!(
404 "cancel ledger: line matched a run-cancel key but is not a \
405 replayable event: {} [{e}]",
406 excerpt(raw)
407 ),
408 })?;
409 slot.insert(ev);
410 }
411 }
412 Ok(())
413 })?;
414
415 // A validated `NodeId` is `n-` + ASCII digits (≤10, so it fits in u64); the
416 // unwrap_or keeps the sort total even for a hypothetical unparseable body.
417 order.sort_by_key(|id| {
418 id.as_str()
419 .strip_prefix("n-")
420 .and_then(|d| d.parse::<u64>().ok())
421 .unwrap_or(0)
422 });
423 let node_status = order
424 .into_iter()
425 .map(|id| {
426 let s = status[&id];
427 (id, s)
428 })
429 .collect();
430 Ok(CancelLedger {
431 node_status,
432 prior_cancel,
433 })
434}
435
436/// Parse a `node.status` / `run.status` status string into a [`Status`],
437/// returning `None` for an unrecognized value (treated as "no transition" so a
438/// corrupt status never aborts the cancel). Goes through serde so the kebab-case
439/// mapping can never drift from the [`Status`] enum.
440fn parse_status(s: &str) -> Option<Status> {
441 serde_json::from_value(Value::String(s.to_owned())).ok()
442}
443
444/// Derive the terminal status a `node.report` asserts from its `success` /
445/// `cancelled` flags, mirroring the reducer's success-XOR-cancelled rule but
446/// *lenient*: a bare/contradictory report yields `None` (no transition — the
447/// node stays live and is cancelled) rather than the reducer's
448/// [`Error::CorruptEventLog`]. The append path rejects such a report before it
449/// is ever committed against a live node, so a `None` here only arises from a
450/// hand-corrupted log, where leaving the node cancellable is the safe default.
451fn report_terminal_status(success: Option<bool>, cancelled: Option<bool>) -> Option<Status> {
452 if cancelled.unwrap_or(false) {
453 // `cancelled: true` with `success: true` is contradictory → no transition.
454 if success == Some(true) {
455 return None;
456 }
457 Some(Status::Cancelled)
458 } else {
459 match success {
460 Some(true) => Some(Status::Done),
461 Some(false) => Some(Status::Failed),
462 None => None,
463 }
464 }
465}
466
467/// Deterministic idempotency key for the synthesized cancel `node.report` of
468/// one node. Stable in `(run_id, node_id)` so a re-`cancel` after a crash that
469/// fsynced the report but never folded its projection finds the prior event and
470/// does not append a duplicate logical-cancel.
471fn node_cancel_key(run_id: &RunId, node_id: &NodeId) -> String {
472 format!("run-cancel:{}:node:{}", run_id.as_str(), node_id.as_str())
473}
474
475/// Deterministic idempotency key for the run's terminal `run.status: cancelled`
476/// event. Stable in `run_id` for the same crash-retry reason as
477/// [`node_cancel_key`].
478fn run_status_cancel_key(run_id: &RunId) -> String {
479 format!("run-cancel:{}:run-status", run_id.as_str())
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::events::{append_and_apply_event, append_event_with_seq, read_all_events};
486 use crate::lock::ACQUIRE_COUNT;
487 use tempfile::TempDir;
488
489 /// Count `node.report` events recorded in the log for one node id.
490 fn report_count(paths: &RunPaths, nid: &str) -> usize {
491 read_all_events(&paths.events())
492 .unwrap()
493 .iter()
494 .filter(|e| {
495 e.kind == "node.report" && e.node_id.as_ref().map(NodeId::as_str) == Some(nid)
496 })
497 .count()
498 }
499
500 fn fresh_run(tmp: &TempDir) -> RunPaths {
501 let run_id = "01jxsnap000000000000000000";
502 let dir = tmp.path().join(run_id);
503 std::fs::create_dir_all(&dir).unwrap();
504 RunPaths::new(dir, run_id).unwrap()
505 }
506
507 /// Parse a `NodeId` for a test append call (the typed envelope id).
508 fn nid(s: &str) -> NodeId {
509 NodeId::parse_str(s).unwrap()
510 }
511
512 /// Drive a run to `count` live nodes (n-0001..) under a created manifest.
513 fn bootstrap(paths: &RunPaths, count: usize) {
514 append_and_apply_event(
515 paths,
516 "run.created",
517 None,
518 None,
519 json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
520 )
521 .unwrap();
522 for i in 1..=count {
523 let node_id = nid(&format!("n-{i:04}"));
524 append_and_apply_event(
525 paths,
526 "node.created",
527 Some(&node_id),
528 None,
529 json!({ "kind": "spinoff" }),
530 )
531 .unwrap();
532 }
533 }
534
535 fn node_status(paths: &RunPaths, nid: &str) -> Status {
536 let id = NodeId::parse_str(nid).unwrap();
537 crate::read_node(paths, &id).unwrap().status
538 }
539
540 #[test]
541 fn cancel_running_run_converges_live_nodes_and_settles_run() {
542 let tmp = TempDir::new().unwrap();
543 let paths = fresh_run(&tmp);
544 bootstrap(&paths, 2);
545
546 let out = cancel_run(&paths, Some("stop")).unwrap();
547 assert!(!out.run_was_already_cancelled);
548 assert_eq!(
549 out.nodes_cancelled
550 .iter()
551 .map(NodeId::as_str)
552 .collect::<Vec<_>>(),
553 vec!["n-0001", "n-0002"]
554 );
555 assert!(out.nodes_already_terminal.is_empty());
556 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
557 assert_eq!(
558 crate::read_manifest(&paths).unwrap().status,
559 Status::Cancelled
560 );
561 }
562
563 #[test]
564 fn cancel_done_run_is_refused_without_mutation() {
565 let tmp = TempDir::new().unwrap();
566 let paths = fresh_run(&tmp);
567 bootstrap(&paths, 1);
568 // Settle the single node, then the run, to Done.
569 append_and_apply_event(
570 &paths,
571 "node.report",
572 Some(&nid("n-0001")),
573 None,
574 json!({ "success": true }),
575 )
576 .unwrap();
577 append_and_apply_event(
578 &paths,
579 "run.status",
580 None,
581 None,
582 json!({ "status": "done" }),
583 )
584 .unwrap();
585 let before = read_all_events(&paths.events()).unwrap().len();
586
587 let err = cancel_run(&paths, None).unwrap_err();
588 assert!(
589 matches!(
590 err,
591 Error::RunAlreadyTerminal {
592 status: Status::Done
593 }
594 ),
595 "got {err:?}"
596 );
597 assert_eq!(
598 read_all_events(&paths.events()).unwrap().len(),
599 before,
600 "a refused cancel must not append any event"
601 );
602 assert_eq!(crate::read_manifest(&paths).unwrap().status, Status::Done);
603 }
604
605 #[test]
606 fn recancel_cancelled_run_converges_straggler_node() {
607 let tmp = TempDir::new().unwrap();
608 let paths = fresh_run(&tmp);
609 bootstrap(&paths, 2);
610 // Simulate an interrupted cancel: run is Cancelled, but n-0002 is still
611 // live (its node.report never landed).
612 append_and_apply_event(
613 &paths,
614 "node.report",
615 Some(&nid("n-0001")),
616 None,
617 json!({ "success": false, "cancelled": true, "reason": "x" }),
618 )
619 .unwrap();
620 append_and_apply_event(
621 &paths,
622 "run.status",
623 None,
624 None,
625 json!({ "status": "cancelled" }),
626 )
627 .unwrap();
628 assert_eq!(node_status(&paths, "n-0002"), Status::Pending);
629
630 let out = cancel_run(&paths, None).unwrap();
631 assert!(out.run_was_already_cancelled);
632 assert_eq!(
633 out.nodes_cancelled
634 .iter()
635 .map(NodeId::as_str)
636 .collect::<Vec<_>>(),
637 vec!["n-0002"],
638 "only the straggler converges"
639 );
640 assert_eq!(
641 out.nodes_already_terminal
642 .iter()
643 .map(NodeId::as_str)
644 .collect::<Vec<_>>(),
645 vec!["n-0001"]
646 );
647 assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
648 }
649
650 #[test]
651 fn recancel_fully_converged_run_is_a_clean_noop() {
652 let tmp = TempDir::new().unwrap();
653 let paths = fresh_run(&tmp);
654 bootstrap(&paths, 1);
655 cancel_run(&paths, None).unwrap(); // first cancel converges everything
656 let before = read_all_events(&paths.events()).unwrap().len();
657
658 let out = cancel_run(&paths, None).unwrap();
659 assert!(out.run_was_already_cancelled);
660 assert!(out.nodes_cancelled.is_empty(), "nothing left to converge");
661 assert_eq!(
662 out.nodes_already_terminal
663 .iter()
664 .map(NodeId::as_str)
665 .collect::<Vec<_>>(),
666 vec!["n-0001"]
667 );
668 assert_eq!(
669 read_all_events(&paths.events()).unwrap().len(),
670 before,
671 "a fully-converged re-cancel appends nothing"
672 );
673 }
674
675 #[test]
676 fn already_terminal_node_is_not_over_reported() {
677 // The honesty guard: a node already settled (terminal) on entry is
678 // reported under `nodes_already_terminal`, never `nodes_cancelled`,
679 // even though it sits in nodes/ alongside a live node.
680 let tmp = TempDir::new().unwrap();
681 let paths = fresh_run(&tmp);
682 bootstrap(&paths, 2);
683 // n-0001 finishes on its own (Done) before the cancel.
684 append_and_apply_event(
685 &paths,
686 "node.report",
687 Some(&nid("n-0001")),
688 None,
689 json!({ "success": true }),
690 )
691 .unwrap();
692
693 let out = cancel_run(&paths, None).unwrap();
694 assert_eq!(
695 out.nodes_cancelled
696 .iter()
697 .map(NodeId::as_str)
698 .collect::<Vec<_>>(),
699 vec!["n-0002"]
700 );
701 assert_eq!(
702 out.nodes_already_terminal
703 .iter()
704 .map(NodeId::as_str)
705 .collect::<Vec<_>>(),
706 vec!["n-0001"]
707 );
708 assert_eq!(
709 node_status(&paths, "n-0001"),
710 Status::Done,
711 "Done node untouched"
712 );
713 assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
714 }
715
716 #[test]
717 fn cancel_run_with_no_nodes_dir_settles_run_only() {
718 let tmp = TempDir::new().unwrap();
719 let paths = fresh_run(&tmp);
720 append_and_apply_event(
721 &paths,
722 "run.created",
723 None,
724 None,
725 json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
726 )
727 .unwrap();
728
729 let out = cancel_run(&paths, None).unwrap();
730 assert!(!out.run_was_already_cancelled);
731 assert!(out.nodes_cancelled.is_empty());
732 assert!(out.nodes_already_terminal.is_empty());
733 assert_eq!(
734 crate::read_manifest(&paths).unwrap().status,
735 Status::Cancelled
736 );
737 }
738
739 #[test]
740 fn blank_note_falls_back_to_default_reason_and_does_not_brick_cancel() {
741 // A `--note ""` (or whitespace-only) must NOT flow an empty `reason`
742 // into the synthesized report — that would be rejected by the reducer
743 // mid-loop and leave the run permanently un-cancellable. It normalizes
744 // to the default reason and the cancel completes cleanly.
745 for blank in ["", " ", "\n\t"] {
746 let tmp = TempDir::new().unwrap();
747 let paths = fresh_run(&tmp);
748 bootstrap(&paths, 1);
749
750 let out = cancel_run(&paths, Some(blank)).unwrap();
751 assert_eq!(
752 out.nodes_cancelled
753 .iter()
754 .map(NodeId::as_str)
755 .collect::<Vec<_>>(),
756 vec!["n-0001"],
757 "blank note {blank:?} still converges the live node"
758 );
759 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
760 let report = crate::read_node(&paths, &NodeId::parse_str("n-0001").unwrap())
761 .unwrap()
762 .last_report
763 .expect("cancel report recorded");
764 assert_eq!(report["reason"], "cancelled by user");
765 }
766 }
767
768 #[test]
769 fn nodes_are_converged_in_numeric_not_lexical_order() {
770 // Past the digit-width boundary, lexical order would place n-10000
771 // before n-9999. The numeric sort keeps the reported order intuitive.
772 let tmp = TempDir::new().unwrap();
773 let paths = fresh_run(&tmp);
774 append_and_apply_event(
775 &paths,
776 "run.created",
777 None,
778 None,
779 json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
780 )
781 .unwrap();
782 for node in ["n-9999", "n-10000", "n-0001"] {
783 append_and_apply_event(
784 &paths,
785 "node.created",
786 Some(&nid(node)),
787 None,
788 json!({ "kind": "spinoff" }),
789 )
790 .unwrap();
791 }
792
793 let out = cancel_run(&paths, None).unwrap();
794 assert_eq!(
795 out.nodes_cancelled
796 .iter()
797 .map(NodeId::as_str)
798 .collect::<Vec<_>>(),
799 vec!["n-0001", "n-9999", "n-10000"],
800 );
801 }
802
803 #[test]
804 fn cancel_synthesizes_report_for_node_with_missing_projection() {
805 // The crash window this fix closes: a `node.created` was appended+fsynced
806 // to the log, but its projection write (`nodes/n-NNNN.json`) was
807 // interrupted. A `nodes/*.json` scan would not see n-0002 and would
808 // cancel the run while leaving a created-but-never-cancelled node a
809 // future rebuild could resurrect as live. Enumerating from the event log
810 // sees it and synthesizes the cancel report.
811 let tmp = TempDir::new().unwrap();
812 let paths = fresh_run(&tmp);
813 bootstrap(&paths, 2);
814 // Delete n-0002's projection file, leaving its `node.created` event in
815 // the log — exactly the interrupted-fold state.
816 let n2 = NodeId::parse_str("n-0002").unwrap();
817 std::fs::remove_file(paths.node(&n2)).unwrap();
818 assert!(
819 read_node_opt(&paths, &n2).unwrap().is_none(),
820 "projection gone"
821 );
822
823 let out = cancel_run(&paths, Some("stop")).unwrap();
824 // Both nodes are cancelled — the projection-present n-0001 AND the
825 // projection-missing n-0002.
826 assert_eq!(
827 out.nodes_cancelled
828 .iter()
829 .map(NodeId::as_str)
830 .collect::<Vec<_>>(),
831 vec!["n-0001", "n-0002"],
832 "the node with a missing projection is still cancelled"
833 );
834 assert!(out.nodes_already_terminal.is_empty());
835 // The source-of-truth log now carries a terminal cancel report for the
836 // node whose projection was missing — so a rebuild reconstructs it as
837 // Cancelled, not live.
838 assert_eq!(report_count(&paths, "n-0002"), 1);
839 assert_eq!(
840 crate::read_manifest(&paths).unwrap().status,
841 Status::Cancelled
842 );
843 }
844
845 #[test]
846 fn cancel_takes_the_run_lock_exactly_once() {
847 // The single-lock honesty guarantee: the whole transaction (N node
848 // reports + the run.status append) runs under ONE flock acquisition, not
849 // one per appended event. Spy on `RunLock::acquire` to prove it.
850 let tmp = TempDir::new().unwrap();
851 let paths = fresh_run(&tmp);
852 bootstrap(&paths, 5);
853
854 // Bootstrap itself takes the lock once per append; only the cancel call
855 // is under measurement.
856 ACQUIRE_COUNT.with(|c| c.set(0));
857 let out = cancel_run(&paths, Some("stop")).unwrap();
858 assert_eq!(out.nodes_cancelled.len(), 5);
859 assert_eq!(
860 ACQUIRE_COUNT.with(std::cell::Cell::get),
861 1,
862 "cancel must take the run lock exactly once, not once per node (N+1)"
863 );
864 }
865
866 #[test]
867 fn cancel_does_not_duplicate_a_node_report_already_in_the_log() {
868 // Crash-retry idempotency: a prior cancel appended+fsynced a node's
869 // cancel `node.report` (carrying the deterministic key) but crashed
870 // before folding its projection, so the node still reads live. A
871 // re-cancel must NOT append a second logical-cancel event for it.
872 let tmp = TempDir::new().unwrap();
873 let paths = fresh_run(&tmp);
874 bootstrap(&paths, 1); // run.created (seq 1) + node.created (seq 2)
875
876 // Durably append the cancel report WITH the deterministic key, but
877 // without folding it — the node stays Pending (live), modeling the
878 // fsynced-but-not-applied window.
879 let node = nid("n-0001");
880 let key = node_cancel_key(&paths.run_id, &node);
881 RunLock::with_lock(&paths, |lock| {
882 append_event_with_seq(
883 lock,
884 &paths,
885 3,
886 "node.report",
887 Some(&node),
888 Some(&key),
889 json!({ "success": false, "cancelled": true, "reason": "x" }),
890 )
891 })
892 .unwrap();
893 assert_eq!(node_status(&paths, "n-0001"), Status::Pending);
894 assert_eq!(report_count(&paths, "n-0001"), 1);
895
896 let out = cancel_run(&paths, None).unwrap();
897 // The node converges (it is reported cancelled) but no duplicate report
898 // is appended — the log still holds exactly one `node.report` for it.
899 assert_eq!(
900 out.nodes_cancelled
901 .iter()
902 .map(NodeId::as_str)
903 .collect::<Vec<_>>(),
904 vec!["n-0001"],
905 );
906 assert_eq!(
907 report_count(&paths, "n-0001"),
908 1,
909 "the already-logged cancel report must not be duplicated"
910 );
911 // Convergence: the crash-stranded projection is folded from the
912 // already-logged event, so the node reads Cancelled (not the stale
913 // Pending) even though no new event was appended for it.
914 assert_eq!(
915 node_status(&paths, "n-0001"),
916 Status::Cancelled,
917 "the already-logged cancel must be re-folded, not just skipped"
918 );
919 assert_eq!(
920 crate::read_manifest(&paths).unwrap().status,
921 Status::Cancelled
922 );
923 }
924
925 #[test]
926 fn cancel_does_not_duplicate_run_status_already_in_the_log() {
927 // The run-status analogue: a prior cancel fsynced `run.status: cancelled`
928 // (with its deterministic key) but crashed before folding the manifest,
929 // so the manifest still reads non-terminal. A re-cancel must not append a
930 // second `run.status: cancelled`.
931 let tmp = TempDir::new().unwrap();
932 let paths = fresh_run(&tmp);
933 bootstrap(&paths, 0); // run.created only (seq 1)
934
935 let key = run_status_cancel_key(&paths.run_id);
936 RunLock::with_lock(&paths, |lock| {
937 append_event_with_seq(
938 lock,
939 &paths,
940 2,
941 "run.status",
942 None,
943 Some(&key),
944 json!({ "status": "cancelled" }),
945 )
946 })
947 .unwrap();
948 // Manifest never folded the cancel, so it is not terminal here.
949 assert_ne!(
950 crate::read_manifest(&paths).unwrap().status,
951 Status::Cancelled
952 );
953 let before = read_all_events(&paths.events()).unwrap().len();
954
955 let out = cancel_run(&paths, None).unwrap();
956 assert!(!out.run_was_already_cancelled);
957 assert_eq!(
958 read_all_events(&paths.events()).unwrap().len(),
959 before,
960 "no duplicate run.status appended when one is already logged"
961 );
962 // Convergence: the manifest is folded from the already-logged
963 // `run.status: cancelled` instead of being left stale.
964 assert_eq!(
965 crate::read_manifest(&paths).unwrap().status,
966 Status::Cancelled,
967 "the already-logged run.status must be re-folded, not just skipped"
968 );
969 }
970
971 #[test]
972 fn cancel_skips_node_terminal_in_log_despite_stale_live_projection() {
973 // cancel-liveness-from-log: a non-cancel terminal event (here a
974 // `node.status` to a terminal value) was fsynced to the log but its
975 // projection fold was crash-interrupted, so `nodes/n-0001.json` still
976 // reads the stale live (Pending) status. The cancel must derive liveness
977 // from the LOG and treat the node as already-terminal — never
978 // synthesizing a cancel that would over-write the log's terminal and
979 // diverge on a future rebuild (which replays node.status: done FIRST and
980 // drops the later cancel).
981 let tmp = TempDir::new().unwrap();
982 let paths = fresh_run(&tmp);
983 bootstrap(&paths, 2); // run.created(1) + node.created n-0001(2), n-0002(3)
984
985 // Raw-append (no fold) a terminal `node.status` for n-0001: the log
986 // records it Done, but the projection stays the stale crash-window
987 // Pending.
988 let n1 = nid("n-0001");
989 RunLock::with_lock(&paths, |lock| {
990 append_event_with_seq(
991 lock,
992 &paths,
993 4,
994 "node.status",
995 Some(&n1),
996 None,
997 json!({ "status": "done" }),
998 )
999 })
1000 .unwrap();
1001 assert_eq!(
1002 node_status(&paths, "n-0001"),
1003 Status::Pending,
1004 "projection is the stale, crash-stranded live status"
1005 );
1006
1007 let out = cancel_run(&paths, Some("stop")).unwrap();
1008 // n-0001 is settled by the log, NOT freshly cancelled; only the
1009 // genuinely live n-0002 is cancelled.
1010 assert_eq!(
1011 out.nodes_already_terminal
1012 .iter()
1013 .map(NodeId::as_str)
1014 .collect::<Vec<_>>(),
1015 vec!["n-0001"],
1016 "the log-terminal node is reported already-terminal, not cancelled"
1017 );
1018 assert_eq!(
1019 out.nodes_cancelled
1020 .iter()
1021 .map(NodeId::as_str)
1022 .collect::<Vec<_>>(),
1023 vec!["n-0002"],
1024 );
1025 // No cancel report was synthesized for n-0001: the log still holds zero
1026 // `node.report` lines for it, so a rebuild reconstructs it from the
1027 // `node.status: done` (Done), not a divergent Cancelled.
1028 assert_eq!(
1029 report_count(&paths, "n-0001"),
1030 0,
1031 "no cancel over-write was appended for the log-terminal node"
1032 );
1033 }
1034
1035 #[test]
1036 fn cancel_skips_node_with_unfolded_success_report_in_log() {
1037 // The issue's headline case: a `node.report { success: true }` fsynced
1038 // but not folded leaves a stale-live projection. Liveness from the log
1039 // settles the node as Done (already-terminal); the old projection-derived
1040 // check would have wrongly cancelled it over its already-logged success.
1041 let tmp = TempDir::new().unwrap();
1042 let paths = fresh_run(&tmp);
1043 bootstrap(&paths, 1); // run.created(1) + node.created n-0001(2)
1044 let n1 = nid("n-0001");
1045 RunLock::with_lock(&paths, |lock| {
1046 append_event_with_seq(
1047 lock,
1048 &paths,
1049 3,
1050 "node.report",
1051 Some(&n1),
1052 None,
1053 json!({ "success": true }),
1054 )
1055 })
1056 .unwrap();
1057 assert_eq!(
1058 node_status(&paths, "n-0001"),
1059 Status::Pending,
1060 "stale live projection (success report fsynced but not folded)"
1061 );
1062
1063 let out = cancel_run(&paths, None).unwrap();
1064 assert_eq!(
1065 out.nodes_already_terminal
1066 .iter()
1067 .map(NodeId::as_str)
1068 .collect::<Vec<_>>(),
1069 vec!["n-0001"],
1070 );
1071 assert!(
1072 out.nodes_cancelled.is_empty(),
1073 "a node the log shows Done must not be cancelled"
1074 );
1075 assert_eq!(
1076 report_count(&paths, "n-0001"),
1077 1,
1078 "only the original success report remains; no cancel was appended"
1079 );
1080 }
1081
1082 #[test]
1083 fn cancel_ledger_streams_large_report_payloads() {
1084 // The streaming ledger skims each line's envelope + a few small status
1085 // fields, never materializing the (here multi-KB) `node.report` `data`
1086 // payload. A node settled by such a report is still correctly seen as
1087 // terminal from the log, and a live sibling is still cancelled — proving
1088 // liveness is derived without holding whole reports in memory.
1089 let tmp = TempDir::new().unwrap();
1090 let paths = fresh_run(&tmp);
1091 bootstrap(&paths, 2);
1092 let big = "x".repeat(64 * 1024);
1093 append_and_apply_event(
1094 &paths,
1095 "node.report",
1096 Some(&nid("n-0001")),
1097 None,
1098 json!({ "success": true, "summary": big }),
1099 )
1100 .unwrap();
1101 assert_eq!(node_status(&paths, "n-0001"), Status::Done);
1102
1103 let out = cancel_run(&paths, Some("stop")).unwrap();
1104 assert_eq!(
1105 out.nodes_already_terminal
1106 .iter()
1107 .map(NodeId::as_str)
1108 .collect::<Vec<_>>(),
1109 vec!["n-0001"],
1110 );
1111 assert_eq!(
1112 out.nodes_cancelled
1113 .iter()
1114 .map(NodeId::as_str)
1115 .collect::<Vec<_>>(),
1116 vec!["n-0002"],
1117 );
1118 }
1119}