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)]
89#[must_use]
90pub struct CancelOutcome {
91 /// True when the run's manifest was already `Cancelled` on entry, so no
92 /// `run.status: cancelled` event was appended. The call still scans and
93 /// converges any straggler nodes (an interrupted earlier cancel), so this
94 /// is a SUCCESS, not an error: "no-op: run was already cancelled,
95 /// converged N additional nodes".
96 pub run_was_already_cancelled: bool,
97 /// Nodes this cancel transaction ensured are terminally cancelled: live
98 /// nodes for which it synthesized and durably appended a terminal cancel
99 /// `node.report` (and folded it), plus any node whose cancel `node.report` a
100 /// prior interrupted cancel had already durably appended (matched by
101 /// `(kind, idempotency_key)`) and which this call converged by *re-folding*
102 /// that event rather than re-appending. Either way the node carries a
103 /// terminal cancel in the source-of-truth log and its projection is folded
104 /// (or, for a still-missing projection, will fold on rebuild — see the
105 /// module docs); none is double-reported against a node that was already
106 /// terminal on entry.
107 pub nodes_cancelled: Vec<NodeId>,
108 /// Nodes whose *status* was already terminal on entry and so were skipped —
109 /// never double-reported as freshly cancelled.
110 pub nodes_already_terminal: Vec<NodeId>,
111}
112
113/// Cancel a run in a single locked transaction. Acquires the run's
114/// [`RunLock`] once for the whole operation, then delegates to
115/// [`cancel_run_unlocked`].
116///
117/// # Errors
118///
119/// - [`Error::RunAlreadyTerminal`] if the run is `Done`/`Failed` — refused
120/// without mutating state.
121/// - I/O / corrupt-log errors from reading the manifest, listing nodes, or
122/// appending events.
123pub fn cancel_run(paths: &RunPaths, note: Option<&str>) -> Result<CancelOutcome> {
124 RunLock::with_lock(paths, |lock| cancel_run_unlocked(lock, paths, note))
125}
126
127/// The locked body of [`cancel_run`]. The `lock: &LockedRun` witness proves the
128/// caller already holds the run's exclusive [`RunLock`]; this is the sanctioned
129/// lock-held composition path so the manifest read, the per-node
130/// read-then-append loop, and the final `run.status` append all share one
131/// critical section (it calls [`append_and_apply_unlocked`], never
132/// [`crate::append_and_apply_event`], which would deadlock by re-locking).
133pub fn cancel_run_unlocked(
134 lock: &LockedRun<'_>,
135 paths: &RunPaths,
136 note: Option<&str>,
137) -> Result<CancelOutcome> {
138 let started = std::time::Instant::now();
139 let manifest = read_manifest(paths)?;
140
141 // Refuse a non-cancelled terminal run BEFORE touching any node: cancelling
142 // a Done/Failed run would synthesize node reports and append a
143 // `run.status: cancelled` the reducer's terminal-state guard then drops,
144 // so the CLI would claim a transition that never happened. An already-
145 // `Cancelled` run is not refused — it falls through to converge stragglers.
146 if manifest.status.is_terminal() && manifest.status != Status::Cancelled {
147 return Err(Error::RunAlreadyTerminal {
148 status: manifest.status,
149 });
150 }
151 let run_was_already_cancelled = manifest.status == Status::Cancelled;
152
153 // Normalize the cancel reason ONCE up front (see [`normalize_cancel_reason`]):
154 // a blank `--note` would flow in as `reason: ""`, which the reducer rejects
155 // and would brick the run's cancellability. It falls back to the default.
156 let reason = normalize_cancel_reason(note);
157
158 // One streaming replay pass over the source-of-truth log: the authoritative
159 // node set *and each node's current status* (both immune to the projection
160 // crash window), plus the prior cancel events already recorded (so a prior
161 // interrupted cancel isn't duplicated — it is re-folded instead).
162 let CancelLedger {
163 node_status,
164 prior_cancel,
165 } = read_cancel_ledger(paths)?;
166
167 let mut nodes_cancelled = Vec::new();
168 let mut nodes_already_terminal = Vec::new();
169
170 for (nid, log_status) in node_status {
171 let key = node_cancel_key(&paths.run_id, &nid);
172 // Convergence path first: this run's cancel already logged a
173 // `node.report` for this node (a prior, possibly crash-interrupted,
174 // cancel). The log is identical whether that report's projection fold
175 // landed or not, so `read_node_opt` is what tells the two apart — an
176 // already-folded terminal projection is a clean no-op reported as
177 // already-terminal, while a crash-stranded still-live projection is
178 // converged by re-folding the already-logged event (no duplicate append)
179 // and reported as cancelled. This is the only remaining projection read,
180 // and it serves convergence, not the liveness decision below.
181 if let Some(prior) = prior_cancel.get(&("node.report".to_owned(), key.clone())) {
182 if let Some(n) = read_node_opt(paths, &nid)? {
183 if n.status.is_terminal() {
184 nodes_already_terminal.push(nid);
185 continue;
186 }
187 }
188 apply_event(paths, prior)?;
189 nodes_cancelled.push(nid);
190 continue;
191 }
192 // No prior cancel for this node: the event log is authoritative for
193 // liveness. A node the log replays as terminal — a non-cancel terminal
194 // (`node.report success` / a `node.status` to a terminal value), or a
195 // cancel logged outside this run's key namespace — is already settled
196 // and skipped, even if a stale projection still reads live (the window
197 // cancel-liveness-from-log closes: the log wins). Only a node the log
198 // shows non-terminal (including a `node.created` whose projection write
199 // was interrupted — the crash window a `nodes/*.json` scan would drop)
200 // gets a synthesized terminal cancel report so the log records it as
201 // cancelled and a future rebuild can't resurrect it as live.
202 if log_status.is_terminal() {
203 nodes_already_terminal.push(nid);
204 continue;
205 }
206 let data = json!({
207 "success": false,
208 "cancelled": true,
209 "reason": reason,
210 "summary": "Run cancelled before agent reported.",
211 "discussion_items": [],
212 "spinoff_proposals": [],
213 "wrap_up_recommendations": []
214 });
215 append_and_apply_unlocked(lock, paths, "node.report", Some(&nid), Some(&key), data)?;
216 nodes_cancelled.push(nid);
217 }
218
219 if !run_was_already_cancelled {
220 let key = run_status_cancel_key(&paths.run_id);
221 if let Some(prior) = prior_cancel.get(&("run.status".to_owned(), key.clone())) {
222 // A prior interrupted cancel already logged the terminal `run.status`
223 // (fsynced before its manifest fold). Re-fold it to converge the
224 // manifest instead of appending a duplicate `run.status: cancelled`.
225 apply_event(paths, prior)?;
226 } else {
227 let mut status_data = serde_json::Map::new();
228 status_data.insert("status".into(), "cancelled".into());
229 // Record the operator note only when one was actually supplied (the
230 // trimmed, non-blank value); a blank `--note` leaves the field unset
231 // rather than writing an empty string.
232 if let Some(n) = note.map(str::trim).filter(|s| !s.is_empty()) {
233 status_data.insert("note".into(), n.into());
234 }
235 append_and_apply_unlocked(
236 lock,
237 paths,
238 "run.status",
239 None,
240 Some(&key),
241 serde_json::Value::Object(status_data),
242 )?;
243 }
244 }
245
246 tracing::debug!(
247 target: "octl_core::cancel",
248 run_id = %paths.run_id,
249 held_ms = started.elapsed().as_millis() as u64,
250 nodes_cancelled = nodes_cancelled.len(),
251 nodes_already_terminal = nodes_already_terminal.len(),
252 "cancel transaction complete",
253 );
254
255 Ok(CancelOutcome {
256 run_was_already_cancelled,
257 nodes_cancelled,
258 nodes_already_terminal,
259 })
260}
261
262/// Outcome of a [`cancel_node`] transaction — a single-node, branch-preserving
263/// cancel for one live fan-out child.
264///
265/// Per-node cancel deliberately leaves the run non-terminal *while any sibling is
266/// still live* (design §2.5 — a stuck child is unblocked without killing the
267/// batch). But when this cancel settles the **last** live node, the run is rolled
268/// up **in the same locked transaction** ([`rolled_up`](Self::rolled_up)) rather
269/// than deferred to the supervisor — so a run whose supervisor has died is never
270/// stranded non-terminal (llm-review C1).
271#[derive(Debug, Clone, PartialEq, Eq)]
272#[must_use]
273pub struct NodeCancelOutcome {
274 /// The node this call targeted (fully resolved).
275 pub node_id: NodeId,
276 /// True when this call ensured the node carries a terminal cancel in the
277 /// source-of-truth log — either by synthesizing and durably appending a fresh
278 /// cancel `node.report`, or by re-folding a prior interrupted cancel's
279 /// already-logged event (crash convergence, no duplicate append). False when
280 /// the node was already terminal on entry (see `already_terminal`).
281 pub cancelled: bool,
282 /// True when the node was *already* terminal on entry (merged, failed, or a
283 /// prior cancel already folded) — a clean idempotent no-op, never a fresh
284 /// cancel. Mutually exclusive with `cancelled`.
285 pub already_terminal: bool,
286 /// `Some(status)` when this cancel settled the last live node and therefore
287 /// rolled the whole run up to a terminal status **under the same lock**
288 /// (`Cancelled` when no sibling failed, `Failed` when one did). `None` when
289 /// siblings remain live (the run stays live) or the run was already terminal
290 /// on entry. Lets the CLI tell the operator whether the run itself is now
291 /// settled rather than implying a rollup that might never come.
292 pub rolled_up: Option<Status>,
293}
294
295/// Cancel exactly ONE live node of a run, preserving its branch + worktree.
296/// Acquires the run's [`RunLock`] once and delegates to
297/// [`cancel_node_unlocked`].
298///
299/// This is the fan-out selectivity primitive (design §2.5, issue
300/// `per-node-run`): where [`cancel_run`] settles every live node and rolls the
301/// run up to `Cancelled` in one shot, this settles a single named node. While
302/// any sibling is still live it appends **only** that node's terminal cancel
303/// `node.report` (no `run.status`) — the run stays live so the batch keeps
304/// running. When it settles the **last** live node it also rolls the run up to a
305/// terminal status **in the same locked transaction** (so a dead supervisor can
306/// never strand the run non-terminal — llm-review C1). The synthesized terminal
307/// cancel `node.report` classifies as [`Cancelled`](crate::Status) →
308/// `Teardown::SourceRelative`, so invariant 5 preserves the node's committed work
309/// rather than force-deleting it.
310///
311/// # Errors
312///
313/// - [`Error::RunAlreadyTerminal`] if the run is `Done`/`Failed` — refused
314/// without mutating state (mirrors [`cancel_run`]; an already-`Cancelled` run
315/// is *not* refused — its live nodes can still be settled).
316/// - [`Error::NodeNotFound`] if `node_id` names no node in the run's log.
317/// - I/O / corrupt-log errors from reading the manifest, replaying the log, or
318/// appending the report.
319pub fn cancel_node(
320 paths: &RunPaths,
321 node_id: &NodeId,
322 note: Option<&str>,
323) -> Result<NodeCancelOutcome> {
324 RunLock::with_lock(paths, |lock| {
325 cancel_node_unlocked(lock, paths, node_id, note)
326 })
327}
328
329/// The locked body of [`cancel_node`]. The `lock: &LockedRun` witness proves the
330/// caller already holds the run's exclusive [`RunLock`], so the manifest read,
331/// the log replay, the convergence read, the single report append, AND the
332/// optional last-node roll-up all share one critical section (it calls
333/// [`append_and_apply_unlocked`], never [`crate::append_and_apply_event`], which
334/// would deadlock by re-locking).
335pub fn cancel_node_unlocked(
336 lock: &LockedRun<'_>,
337 paths: &RunPaths,
338 node_id: &NodeId,
339 note: Option<&str>,
340) -> Result<NodeCancelOutcome> {
341 // Refuse a non-cancelled terminal run up front, mirroring `cancel_run`: a
342 // `Done`/`Failed` run's nodes are all terminal, so appending a fresh cancel
343 // `node.report` would either bloat the log with a dead post-terminal event or
344 // (on rebuild) flip a settled node — divergence. An already-`Cancelled` run
345 // is NOT refused: it may still carry a straggler live node to settle
346 // (llm-review C4).
347 let manifest = read_manifest(paths)?;
348 if manifest.status.is_terminal() && manifest.status != Status::Cancelled {
349 return Err(Error::RunAlreadyTerminal {
350 status: manifest.status,
351 });
352 }
353
354 // One streaming replay pass over the source-of-truth log gives the
355 // authoritative node set *and* each node's log-derived status (both immune to
356 // the projection crash window), plus this run's already-logged cancel events
357 // so a prior interrupted cancel is re-folded, never duplicated.
358 let CancelLedger {
359 node_status,
360 prior_cancel,
361 } = read_cancel_ledger(paths)?;
362
363 // The log is authoritative for the node set: a node whose `node.created` was
364 // fsynced but whose projection write was crash-interrupted is still
365 // resolvable here (a `nodes/*.json` scan would miss it). A genuinely absent
366 // id is a caller error.
367 let log_status = node_status
368 .iter()
369 .find(|(nid, _)| nid == node_id)
370 .map(|(_, s)| *s)
371 .ok_or_else(|| Error::NodeNotFound {
372 node_id: node_id.as_str().to_owned(),
373 })?;
374
375 let key = node_cancel_key(&paths.run_id, node_id);
376
377 // Settle the target node. `cancelled` = this call ensured a terminal cancel
378 // in the log (fresh append or crash-convergence re-fold); `already_terminal`
379 // = the node was already terminal on entry (idempotent no-op).
380 let (cancelled, already_terminal) =
381 if let Some(prior) = prior_cancel.get(&("node.report".to_owned(), key.clone())) {
382 // Convergence path: this run's cancel already logged a `node.report`
383 // for this node (a prior, possibly crash-interrupted, per-node or
384 // whole-run cancel). The log is identical whether that report's
385 // projection fold landed or not, so `read_node_opt` tells the two
386 // apart — an already-folded terminal projection is a clean no-op,
387 // while a crash-stranded still-live projection is converged by
388 // re-folding the already-logged event (no duplicate append).
389 let folded_terminal =
390 read_node_opt(paths, node_id)?.is_some_and(|n| n.status.is_terminal());
391 if folded_terminal {
392 (false, true)
393 } else {
394 apply_event(paths, prior)?;
395 (true, false)
396 }
397 } else if log_status.is_terminal() {
398 // No prior cancel: the log is authoritative for liveness. A node the
399 // log replays as terminal — a natural success/failure, or a cancel
400 // logged outside this run's key namespace — is already settled, even
401 // if a stale projection still reads live (the log wins).
402 (false, true)
403 } else {
404 let reason = normalize_cancel_reason(note);
405 let data = json!({
406 "success": false,
407 "cancelled": true,
408 "reason": reason,
409 "summary": "Node cancelled before agent reported.",
410 "discussion_items": [],
411 "spinoff_proposals": [],
412 "wrap_up_recommendations": []
413 });
414 append_and_apply_unlocked(lock, paths, "node.report", Some(node_id), Some(&key), data)?;
415 (true, false)
416 };
417
418 // Last-node roll-up (llm-review C1): if the run is still non-terminal but
419 // every node is now terminal, terminalize the run HERE, under the same lock,
420 // rather than deferring to the supervisor — which may be dead, leaving the
421 // run stranded `pending` with no live node. The aggregate is derived from the
422 // log-authoritative `node_status` (with the target's post-cancel status
423 // overridden), so it never mis-terminalizes over a stale projection. The
424 // decision runs only when NO sibling is live, so the "don't terminalize while
425 // a sibling runs" invariant holds.
426 let rolled_up = maybe_roll_up_run(
427 lock,
428 paths,
429 manifest.status,
430 &node_status,
431 node_id,
432 cancelled,
433 log_status,
434 &prior_cancel,
435 )?;
436
437 Ok(NodeCancelOutcome {
438 node_id: node_id.clone(),
439 cancelled,
440 already_terminal,
441 rolled_up,
442 })
443}
444
445/// Roll the run up to a terminal status when this per-node cancel settled the
446/// last live node. Returns the status appended, or `None` when the run stays
447/// live (a sibling is still live) or was already terminal on entry.
448///
449/// Shares the whole-run cancel's `run-cancel:<run>:run-status` idempotency key
450/// (via [`run_status_cancel_key`]) so the three run-status producers in the
451/// cancel family — whole-run cancel, this last-node roll-up, and a crash-retry of
452/// either — converge on ONE logical `run.status` rather than duplicating it: a
453/// prior interrupted append captured in `prior_cancel` is re-folded, and a later
454/// `cancel_run` finds the same key and skips (llm-review C2/C4). The supervisor's
455/// own `supervise::cleanup::rollup_status` uses a different key, but it fires only
456/// while the manifest is non-terminal, so once this append lands the supervisor's
457/// tick reads the run terminal and no-ops.
458#[allow(clippy::too_many_arguments)]
459fn maybe_roll_up_run(
460 lock: &LockedRun<'_>,
461 paths: &RunPaths,
462 run_status_on_entry: Status,
463 node_status: &[(NodeId, Status)],
464 target: &NodeId,
465 target_cancelled: bool,
466 target_log_status: Status,
467 prior_cancel: &HashMap<(String, String), Event>,
468) -> Result<Option<Status>> {
469 if run_status_on_entry.is_terminal() {
470 // The run was already terminal on entry (an already-`Cancelled` run whose
471 // straggler we just settled) — nothing to roll up.
472 return Ok(None);
473 }
474 // The target's effective post-cancel status: `Cancelled` if this call settled
475 // it, else its log-derived status (an already-terminal node).
476 let effective_target = if target_cancelled {
477 Status::Cancelled
478 } else {
479 target_log_status
480 };
481 let statuses = node_status
482 .iter()
483 .map(|(nid, s)| if nid == target { effective_target } else { *s });
484 let Some(agg) = crate::aggregate_terminal_status(statuses) else {
485 // A sibling is still live — the run stays live, as designed.
486 return Ok(None);
487 };
488
489 let key = run_status_cancel_key(&paths.run_id);
490 if let Some(prior) = prior_cancel.get(&("run.status".to_owned(), key.clone())) {
491 // A prior interrupted cancel already logged the terminal `run.status`
492 // (fsynced before its manifest fold). Re-fold it to converge the manifest
493 // instead of appending a duplicate.
494 apply_event(paths, prior)?;
495 } else {
496 // `Status` serializes kebab-case (`cancelled`/`failed`/`done`) — the
497 // exact shape the reducer's `run.status` handler parses.
498 append_and_apply_unlocked(
499 lock,
500 paths,
501 "run.status",
502 None,
503 Some(&key),
504 json!({ "status": agg }),
505 )?;
506 }
507 Ok(Some(agg))
508}
509
510/// Normalize a `--note` into the terminal cancel report's `reason`. An empty or
511/// whitespace-only note would otherwise flow in as `reason: ""`, which the
512/// reducer rejects (`CancelledRequiresReason`) — aborting the transaction and,
513/// since a retry reuses the same bad note, leaving the node/run permanently
514/// un-cancellable. A blank note falls back to the default.
515fn normalize_cancel_reason(note: Option<&str>) -> &str {
516 note.map(str::trim)
517 .filter(|s| !s.is_empty())
518 .unwrap_or("cancelled by user")
519}
520
521/// Cancel-relevant facts replayed from `events.jsonl` in one streaming pass
522/// under the held lock.
523struct CancelLedger {
524 /// Every node a `node.created` event introduced, paired with the status the
525 /// log replays for it, deduped and sorted by numeric suffix. The
526 /// authoritative live-node set *and* per-node liveness: replayed from the
527 /// source of truth, so it includes a node whose projection write was
528 /// crash-interrupted (the node a `nodes/*.json` scan would miss) and reports
529 /// a node terminal whenever the log says so even if the projection still
530 /// reads live (the window [`crate::events`] documents the log leading the
531 /// projections through).
532 node_status: Vec<(NodeId, Status)>,
533 /// Cancel events this run already logged, keyed by `(kind, idempotency_key)`
534 /// and limited to this run's `run-cancel:<run_id>:` key namespace (first
535 /// occurrence wins, mirroring [`crate::events::find_prior_with_key`]). The
536 /// cancel loop looks an entry up by its deterministic key to (a) avoid
537 /// re-appending a duplicate and (b) re-fold the event so a crash-stranded
538 /// projection converges. Keying by `(kind, key)` — not the bare string —
539 /// keeps a coincidental or forged key on an unrelated `kind` from masking a
540 /// real cancel append. Only these few lines have their full [`Event`] payload
541 /// materialized; every other line is skimmed envelope-only.
542 prior_cancel: HashMap<(String, String), Event>,
543}
544
545/// Envelope + the few small `data` fields the cancel ledger needs from each
546/// line, skimmed by [`for_each_event_probe`] without materializing the
547/// (potentially multi-KB) full `data` payload. serde ignores every other field,
548/// so a rich `node.report` is scanned but never allocated.
549#[derive(Deserialize)]
550struct CancelProbe {
551 kind: String,
552 #[serde(default)]
553 node_id: Option<NodeId>,
554 #[serde(default)]
555 idempotency_key: Option<String>,
556 #[serde(default)]
557 data: CancelProbeData,
558}
559
560/// The status-bearing `data` fields of `node.status` / `node.report`. All
561/// optional: any other event kind simply leaves them `None`.
562#[derive(Deserialize, Default)]
563struct CancelProbeData {
564 #[serde(default)]
565 status: Option<String>,
566 #[serde(default)]
567 success: Option<bool>,
568 #[serde(default)]
569 cancelled: Option<bool>,
570}
571
572/// Replay `events.jsonl` once, streaming, to build the [`CancelLedger`].
573///
574/// Reads through [`RunPaths::checked_events`] so a symlinked event log is
575/// refused, matching the mutation path — the cancel decision must not be made
576/// from content redirected outside the run tree. Uses [`for_each_event_probe`],
577/// which shares the crate's torn-tail policy: a crash-truncated final line is
578/// dropped as an uncommitted partial write, while any *interior* unparseable
579/// line is surfaced as [`Error::CorruptEventLog`] — so a corrupt log fails the
580/// cancel loudly rather than silently dropping a node. A missing log yields an
581/// empty ledger (run never appended an event — nothing to cancel).
582///
583/// Per-node status is accumulated by the shared [`NodeStatusAcc`] state machine
584/// (which mirrors the reducer's terminal-state guard) — the same accumulator the
585/// supervisor's log-authoritative [`read_node_statuses`] uses, so the cancel path
586/// and the supervisor roll-up can never derive a different node set or status
587/// from the same log. Node ids come out sorted by numeric suffix.
588fn read_cancel_ledger(paths: &RunPaths) -> Result<CancelLedger> {
589 let events_path = paths.checked_events()?;
590 let prefix = format!("run-cancel:{}:", paths.run_id.as_str());
591 let mut acc = NodeStatusAcc::default();
592 let mut prior_cancel: HashMap<(String, String), Event> = HashMap::new();
593
594 for_each_event_probe::<CancelProbe, _>(&events_path, |probe, raw| {
595 acc.observe(&probe);
596 // Capture only this run's cancel events, keyed by (kind, key), and only
597 // for those materialize the full payload the re-fold path needs.
598 if let Some(key) = probe
599 .idempotency_key
600 .as_deref()
601 .filter(|k| k.starts_with(&prefix))
602 {
603 let entry = (probe.kind.clone(), key.to_owned());
604 if let std::collections::hash_map::Entry::Vacant(slot) = prior_cancel.entry(entry) {
605 let ev: Event =
606 serde_json::from_slice(raw).map_err(|e| Error::CorruptEventLog {
607 path: events_path.clone(),
608 reason: format!(
609 "cancel ledger: line matched a run-cancel key but is not a \
610 replayable event: {} [{e}]",
611 excerpt(raw)
612 ),
613 })?;
614 slot.insert(ev);
615 }
616 }
617 Ok(())
618 })?;
619
620 Ok(CancelLedger {
621 node_status: acc.finish(),
622 prior_cancel,
623 })
624}
625
626/// The log-authoritative per-node status set for a run, replayed once from
627/// `events.jsonl` — the source-of-truth alternative to a `nodes/*.json`
628/// projection scan.
629///
630/// Returns every node a `node.created` event introduced, paired with the status
631/// the log replays for it, deduped and sorted by numeric suffix ([`NodeId`]
632/// order). Both the node set and each node's status come from the log, so the
633/// result includes a node whose `node.created` was fsynced while its projection
634/// write was crash-interrupted (the node a `nodes/*.json` scan would silently
635/// drop) and reports a node terminal whenever the log says so even if its
636/// projection still reads live (the window [`crate::events`] documents the log
637/// leading the projections through).
638///
639/// This is what lets a supervisor's run-status roll-up stay log-authoritative:
640/// terminalizing a run from the projection subset can miss a log-visible live
641/// node and roll the run up while it is still running, which a later
642/// `rebuild_projections` would then resurrect as live under a terminal run
643/// (violating "a run must not terminalize while a log-visible node is live" —
644/// issue `rollup-status-log-authoritative`). Feeding this into
645/// [`aggregate_terminal_status`](crate::aggregate_terminal_status) closes that
646/// window. It is the read half [`cancel_node`]'s in-lock self-roll-up already
647/// uses via the cancel ledger; both now share the `NodeStatusAcc` state machine
648/// so the supervisor tick and the cancel path can never diverge.
649///
650/// Reads through `RunPaths::checked_events` (a symlinked log is refused) and
651/// shares the crate's streaming torn-tail policy: a crash-truncated final line
652/// is dropped, an interior unparseable line surfaces as
653/// [`Error::CorruptEventLog`], and a missing log yields an empty set.
654///
655/// **Cost.** One streaming pass over the whole log — `O(total events)`, not
656/// `O(nodes)`. Memory stays bounded (each line is skimmed envelope + a few small
657/// status fields, never the full `node.report` payload — a run with hundreds of
658/// nodes and multi-KB reports is scanned without ever holding a report in
659/// memory), but the *work* is linear in the event count, not the node count. A
660/// caller that polls this every tick (the supervisor roll-up) re-reads from byte
661/// 0 each time; at the tool's scale (tens of nodes, hundreds of events,
662/// multi-second ticks) that is negligible, but an incremental fold that resumes
663/// from the last consumed offset would be the optimization if a run's log ever
664/// grows large enough to matter.
665///
666/// # Errors
667///
668/// I/O errors reading the log, a rejected symlinked path, or an interior corrupt
669/// event line.
670pub fn read_node_statuses(paths: &RunPaths) -> Result<Vec<(NodeId, Status)>> {
671 let events_path = paths.checked_events()?;
672 let mut acc = NodeStatusAcc::default();
673 for_each_event_probe::<CancelProbe, _>(&events_path, |probe, _raw| {
674 acc.observe(&probe);
675 Ok(())
676 })?;
677 Ok(acc.finish())
678}
679
680/// Streaming accumulator for log-derived per-node status: the shared state
681/// machine behind both the cancel ledger ([`read_cancel_ledger`]) and the
682/// supervisor's log-authoritative roll-up ([`read_node_statuses`]), so the two
683/// can never derive a different node set or status from the same log.
684///
685/// Mirrors the reducer's terminal-state guard exactly: `node.created` seeds
686/// [`Status::Pending`] (idempotent on replay — a second `node.created` for the
687/// same id is a no-op, matching the reducer's existence guard), and
688/// `node.status` / `node.report` transition a node only while it is still
689/// non-terminal. A `node.status` / `node.report` for an id never introduced by a
690/// `node.created` is ignored, exactly as the reducer no-ops a status/report
691/// against a non-existent node. Malformed status fields degrade gracefully (the
692/// node is left at its current status — for the cancel path that keeps the node
693/// non-terminal, hence cancellable) rather than aborting — the append path
694/// already validates every committed event, so a `None` transition only arises
695/// from a hand-corrupted log.
696///
697/// **`node.retry` is deliberately not folded, and that is exact.** In the reducer
698/// (`reduce_node_retry`) a retry against an already-terminal node is a no-op (a
699/// settled node is frozen) and a retry against a *live* node only rewires it back
700/// to `Pending`. So `node.retry` never crosses the terminal/live boundary in
701/// either direction: a node that is terminal here is terminal in the reducer, and
702/// one that is live here (whatever its exact non-terminal value) is live there.
703/// Since the only consumers ([`read_node_statuses`] → `aggregate_terminal_status`,
704/// and the cancel loop) classify solely on terminal-vs-live, ignoring
705/// `node.retry` derives the same answer the reducer would — it is not a missed
706/// transition.
707#[derive(Default)]
708struct NodeStatusAcc {
709 /// Node ids in creation order (re-sorted by numeric suffix at [`finish`]).
710 order: Vec<NodeId>,
711 /// Each node's current log-derived status.
712 status: HashMap<NodeId, Status>,
713}
714
715impl NodeStatusAcc {
716 /// Fold one skimmed event line ([`CancelProbe`]) into the per-node map.
717 fn observe(&mut self, probe: &CancelProbe) {
718 match probe.kind.as_str() {
719 "node.created" => {
720 if let Some(nid) = &probe.node_id {
721 if !self.status.contains_key(nid) {
722 self.order.push(nid.clone());
723 self.status.insert(nid.clone(), Status::Pending);
724 }
725 }
726 }
727 "node.status" => {
728 if let Some(nid) = &probe.node_id {
729 if let Some(cur) = self.status.get_mut(nid) {
730 if !cur.is_terminal() {
731 if let Some(ns) = probe.data.status.as_deref().and_then(parse_status) {
732 *cur = ns;
733 }
734 }
735 }
736 }
737 }
738 "node.report" => {
739 if let Some(nid) = &probe.node_id {
740 if let Some(cur) = self.status.get_mut(nid) {
741 // Terminal guard *before* deriving the outcome, mirroring
742 // the reducer: a report against an already-terminal node
743 // is a dead event (its payload may even be a bare `{}`).
744 if !cur.is_terminal() {
745 if let Some(ns) =
746 report_terminal_status(probe.data.success, probe.data.cancelled)
747 {
748 *cur = ns;
749 }
750 }
751 }
752 }
753 }
754 _ => {}
755 }
756 }
757
758 /// Consume into the sorted `(NodeId, Status)` list. Node ids are sorted by
759 /// numeric suffix (not lexically), so a run past the digit-width boundary
760 /// where `n-10000` would otherwise sort before `n-9999` stays intuitive (see
761 /// [`NodeId`]). A validated `NodeId` is `n-` + ASCII digits (≤10, so it fits
762 /// in u64); the `unwrap_or` keeps the sort total for a hypothetical
763 /// unparseable body.
764 fn finish(mut self) -> Vec<(NodeId, Status)> {
765 self.order.sort_by_key(|id| {
766 id.as_str()
767 .strip_prefix("n-")
768 .and_then(|d| d.parse::<u64>().ok())
769 .unwrap_or(0)
770 });
771 self.order
772 .into_iter()
773 .map(|id| {
774 let s = self.status[&id];
775 (id, s)
776 })
777 .collect()
778 }
779}
780
781/// Parse a `node.status` / `run.status` status string into a [`Status`],
782/// returning `None` for an unrecognized value (treated as "no transition" so a
783/// corrupt status never aborts the cancel). Goes through serde so the kebab-case
784/// mapping can never drift from the [`Status`] enum.
785fn parse_status(s: &str) -> Option<Status> {
786 serde_json::from_value(Value::String(s.to_owned())).ok()
787}
788
789/// Derive the terminal status a `node.report` asserts from its `success` /
790/// `cancelled` flags, mirroring the reducer's success-XOR-cancelled rule but
791/// *lenient*: a bare/contradictory report yields `None` (no transition — the
792/// node stays live and is cancelled) rather than the reducer's
793/// [`Error::CorruptEventLog`]. The append path rejects such a report before it
794/// is ever committed against a live node, so a `None` here only arises from a
795/// hand-corrupted log, where leaving the node cancellable is the safe default.
796fn report_terminal_status(success: Option<bool>, cancelled: Option<bool>) -> Option<Status> {
797 if cancelled.unwrap_or(false) {
798 // `cancelled: true` with `success: true` is contradictory → no transition.
799 if success == Some(true) {
800 return None;
801 }
802 Some(Status::Cancelled)
803 } else {
804 match success {
805 Some(true) => Some(Status::Done),
806 Some(false) => Some(Status::Failed),
807 None => None,
808 }
809 }
810}
811
812/// Deterministic idempotency key for the synthesized cancel `node.report` of
813/// one node. Stable in `(run_id, node_id)` so a re-`cancel` after a crash that
814/// fsynced the report but never folded its projection finds the prior event and
815/// does not append a duplicate logical-cancel.
816fn node_cancel_key(run_id: &RunId, node_id: &NodeId) -> String {
817 format!("run-cancel:{}:node:{}", run_id.as_str(), node_id.as_str())
818}
819
820/// Deterministic idempotency key for the run's terminal `run.status: cancelled`
821/// event. Stable in `run_id` for the same crash-retry reason as
822/// [`node_cancel_key`].
823fn run_status_cancel_key(run_id: &RunId) -> String {
824 format!("run-cancel:{}:run-status", run_id.as_str())
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use crate::events::{append_and_apply_event, append_event_with_seq, read_all_events};
831 use crate::lock::ACQUIRE_COUNT;
832 use tempfile::TempDir;
833
834 /// Count `node.report` events recorded in the log for one node id.
835 fn report_count(paths: &RunPaths, nid: &str) -> usize {
836 read_all_events(&paths.events())
837 .unwrap()
838 .iter()
839 .filter(|e| {
840 e.kind == "node.report" && e.node_id.as_ref().map(NodeId::as_str) == Some(nid)
841 })
842 .count()
843 }
844
845 fn fresh_run(tmp: &TempDir) -> RunPaths {
846 let run_id = "01jxsnap000000000000000000";
847 let dir = tmp.path().join(run_id);
848 std::fs::create_dir_all(&dir).unwrap();
849 RunPaths::new(dir, run_id).unwrap()
850 }
851
852 /// Parse a `NodeId` for a test append call (the typed envelope id).
853 fn nid(s: &str) -> NodeId {
854 NodeId::parse_str(s).unwrap()
855 }
856
857 /// Drive a run to `count` live nodes (n-0001..) under a created manifest.
858 fn bootstrap(paths: &RunPaths, count: usize) {
859 append_and_apply_event(
860 paths,
861 "run.created",
862 None,
863 None,
864 json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
865 )
866 .unwrap();
867 for i in 1..=count {
868 let node_id = nid(&format!("n-{i:04}"));
869 append_and_apply_event(
870 paths,
871 "node.created",
872 Some(&node_id),
873 None,
874 json!({ "kind": "spinoff" }),
875 )
876 .unwrap();
877 }
878 }
879
880 fn node_status(paths: &RunPaths, nid: &str) -> Status {
881 let id = NodeId::parse_str(nid).unwrap();
882 crate::read_node(paths, &id).unwrap().status
883 }
884
885 #[test]
886 fn cancel_running_run_converges_live_nodes_and_settles_run() {
887 let tmp = TempDir::new().unwrap();
888 let paths = fresh_run(&tmp);
889 bootstrap(&paths, 2);
890
891 let out = cancel_run(&paths, Some("stop")).unwrap();
892 assert!(!out.run_was_already_cancelled);
893 assert_eq!(
894 out.nodes_cancelled
895 .iter()
896 .map(NodeId::as_str)
897 .collect::<Vec<_>>(),
898 vec!["n-0001", "n-0002"]
899 );
900 assert!(out.nodes_already_terminal.is_empty());
901 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
902 assert_eq!(
903 crate::read_manifest(&paths).unwrap().status,
904 Status::Cancelled
905 );
906 }
907
908 #[test]
909 fn cancel_done_run_is_refused_without_mutation() {
910 let tmp = TempDir::new().unwrap();
911 let paths = fresh_run(&tmp);
912 bootstrap(&paths, 1);
913 // Settle the single node, then the run, to Done.
914 append_and_apply_event(
915 &paths,
916 "node.report",
917 Some(&nid("n-0001")),
918 None,
919 json!({ "success": true }),
920 )
921 .unwrap();
922 append_and_apply_event(
923 &paths,
924 "run.status",
925 None,
926 None,
927 json!({ "status": "done" }),
928 )
929 .unwrap();
930 let before = read_all_events(&paths.events()).unwrap().len();
931
932 let err = cancel_run(&paths, None).unwrap_err();
933 assert!(
934 matches!(
935 err,
936 Error::RunAlreadyTerminal {
937 status: Status::Done
938 }
939 ),
940 "got {err:?}"
941 );
942 assert_eq!(
943 read_all_events(&paths.events()).unwrap().len(),
944 before,
945 "a refused cancel must not append any event"
946 );
947 assert_eq!(crate::read_manifest(&paths).unwrap().status, Status::Done);
948 }
949
950 #[test]
951 fn recancel_cancelled_run_converges_straggler_node() {
952 let tmp = TempDir::new().unwrap();
953 let paths = fresh_run(&tmp);
954 bootstrap(&paths, 2);
955 // Simulate an interrupted cancel: run is Cancelled, but n-0002 is still
956 // live (its node.report never landed).
957 append_and_apply_event(
958 &paths,
959 "node.report",
960 Some(&nid("n-0001")),
961 None,
962 json!({ "success": false, "cancelled": true, "reason": "x" }),
963 )
964 .unwrap();
965 append_and_apply_event(
966 &paths,
967 "run.status",
968 None,
969 None,
970 json!({ "status": "cancelled" }),
971 )
972 .unwrap();
973 assert_eq!(node_status(&paths, "n-0002"), Status::Pending);
974
975 let out = cancel_run(&paths, None).unwrap();
976 assert!(out.run_was_already_cancelled);
977 assert_eq!(
978 out.nodes_cancelled
979 .iter()
980 .map(NodeId::as_str)
981 .collect::<Vec<_>>(),
982 vec!["n-0002"],
983 "only the straggler converges"
984 );
985 assert_eq!(
986 out.nodes_already_terminal
987 .iter()
988 .map(NodeId::as_str)
989 .collect::<Vec<_>>(),
990 vec!["n-0001"]
991 );
992 assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
993 }
994
995 #[test]
996 fn recancel_fully_converged_run_is_a_clean_noop() {
997 let tmp = TempDir::new().unwrap();
998 let paths = fresh_run(&tmp);
999 bootstrap(&paths, 1);
1000 let _ = cancel_run(&paths, None).unwrap(); // first cancel converges everything
1001 let before = read_all_events(&paths.events()).unwrap().len();
1002
1003 let out = cancel_run(&paths, None).unwrap();
1004 assert!(out.run_was_already_cancelled);
1005 assert!(out.nodes_cancelled.is_empty(), "nothing left to converge");
1006 assert_eq!(
1007 out.nodes_already_terminal
1008 .iter()
1009 .map(NodeId::as_str)
1010 .collect::<Vec<_>>(),
1011 vec!["n-0001"]
1012 );
1013 assert_eq!(
1014 read_all_events(&paths.events()).unwrap().len(),
1015 before,
1016 "a fully-converged re-cancel appends nothing"
1017 );
1018 }
1019
1020 #[test]
1021 fn already_terminal_node_is_not_over_reported() {
1022 // The honesty guard: a node already settled (terminal) on entry is
1023 // reported under `nodes_already_terminal`, never `nodes_cancelled`,
1024 // even though it sits in nodes/ alongside a live node.
1025 let tmp = TempDir::new().unwrap();
1026 let paths = fresh_run(&tmp);
1027 bootstrap(&paths, 2);
1028 // n-0001 finishes on its own (Done) before the cancel.
1029 append_and_apply_event(
1030 &paths,
1031 "node.report",
1032 Some(&nid("n-0001")),
1033 None,
1034 json!({ "success": true }),
1035 )
1036 .unwrap();
1037
1038 let out = cancel_run(&paths, None).unwrap();
1039 assert_eq!(
1040 out.nodes_cancelled
1041 .iter()
1042 .map(NodeId::as_str)
1043 .collect::<Vec<_>>(),
1044 vec!["n-0002"]
1045 );
1046 assert_eq!(
1047 out.nodes_already_terminal
1048 .iter()
1049 .map(NodeId::as_str)
1050 .collect::<Vec<_>>(),
1051 vec!["n-0001"]
1052 );
1053 assert_eq!(
1054 node_status(&paths, "n-0001"),
1055 Status::Done,
1056 "Done node untouched"
1057 );
1058 assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
1059 }
1060
1061 #[test]
1062 fn cancel_run_with_no_nodes_dir_settles_run_only() {
1063 let tmp = TempDir::new().unwrap();
1064 let paths = fresh_run(&tmp);
1065 append_and_apply_event(
1066 &paths,
1067 "run.created",
1068 None,
1069 None,
1070 json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1071 )
1072 .unwrap();
1073
1074 let out = cancel_run(&paths, None).unwrap();
1075 assert!(!out.run_was_already_cancelled);
1076 assert!(out.nodes_cancelled.is_empty());
1077 assert!(out.nodes_already_terminal.is_empty());
1078 assert_eq!(
1079 crate::read_manifest(&paths).unwrap().status,
1080 Status::Cancelled
1081 );
1082 }
1083
1084 #[test]
1085 fn blank_note_falls_back_to_default_reason_and_does_not_brick_cancel() {
1086 // A `--note ""` (or whitespace-only) must NOT flow an empty `reason`
1087 // into the synthesized report — that would be rejected by the reducer
1088 // mid-loop and leave the run permanently un-cancellable. It normalizes
1089 // to the default reason and the cancel completes cleanly.
1090 for blank in ["", " ", "\n\t"] {
1091 let tmp = TempDir::new().unwrap();
1092 let paths = fresh_run(&tmp);
1093 bootstrap(&paths, 1);
1094
1095 let out = cancel_run(&paths, Some(blank)).unwrap();
1096 assert_eq!(
1097 out.nodes_cancelled
1098 .iter()
1099 .map(NodeId::as_str)
1100 .collect::<Vec<_>>(),
1101 vec!["n-0001"],
1102 "blank note {blank:?} still converges the live node"
1103 );
1104 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
1105 let report = crate::read_node(&paths, &NodeId::parse_str("n-0001").unwrap())
1106 .unwrap()
1107 .last_report
1108 .expect("cancel report recorded");
1109 assert_eq!(report["reason"], "cancelled by user");
1110 }
1111 }
1112
1113 #[test]
1114 fn nodes_are_converged_in_numeric_not_lexical_order() {
1115 // Past the digit-width boundary, lexical order would place n-10000
1116 // before n-9999. The numeric sort keeps the reported order intuitive.
1117 let tmp = TempDir::new().unwrap();
1118 let paths = fresh_run(&tmp);
1119 append_and_apply_event(
1120 &paths,
1121 "run.created",
1122 None,
1123 None,
1124 json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1125 )
1126 .unwrap();
1127 for node in ["n-9999", "n-10000", "n-0001"] {
1128 append_and_apply_event(
1129 &paths,
1130 "node.created",
1131 Some(&nid(node)),
1132 None,
1133 json!({ "kind": "spinoff" }),
1134 )
1135 .unwrap();
1136 }
1137
1138 let out = cancel_run(&paths, None).unwrap();
1139 assert_eq!(
1140 out.nodes_cancelled
1141 .iter()
1142 .map(NodeId::as_str)
1143 .collect::<Vec<_>>(),
1144 vec!["n-0001", "n-9999", "n-10000"],
1145 );
1146 }
1147
1148 #[test]
1149 fn cancel_synthesizes_report_for_node_with_missing_projection() {
1150 // The crash window this fix closes: a `node.created` was appended+fsynced
1151 // to the log, but its projection write (`nodes/n-NNNN.json`) was
1152 // interrupted. A `nodes/*.json` scan would not see n-0002 and would
1153 // cancel the run while leaving a created-but-never-cancelled node a
1154 // future rebuild could resurrect as live. Enumerating from the event log
1155 // sees it and synthesizes the cancel report.
1156 let tmp = TempDir::new().unwrap();
1157 let paths = fresh_run(&tmp);
1158 bootstrap(&paths, 2);
1159 // Delete n-0002's projection file, leaving its `node.created` event in
1160 // the log — exactly the interrupted-fold state.
1161 let n2 = NodeId::parse_str("n-0002").unwrap();
1162 std::fs::remove_file(paths.node(&n2)).unwrap();
1163 assert!(
1164 read_node_opt(&paths, &n2).unwrap().is_none(),
1165 "projection gone"
1166 );
1167
1168 let out = cancel_run(&paths, Some("stop")).unwrap();
1169 // Both nodes are cancelled — the projection-present n-0001 AND the
1170 // projection-missing n-0002.
1171 assert_eq!(
1172 out.nodes_cancelled
1173 .iter()
1174 .map(NodeId::as_str)
1175 .collect::<Vec<_>>(),
1176 vec!["n-0001", "n-0002"],
1177 "the node with a missing projection is still cancelled"
1178 );
1179 assert!(out.nodes_already_terminal.is_empty());
1180 // The source-of-truth log now carries a terminal cancel report for the
1181 // node whose projection was missing — so a rebuild reconstructs it as
1182 // Cancelled, not live.
1183 assert_eq!(report_count(&paths, "n-0002"), 1);
1184 assert_eq!(
1185 crate::read_manifest(&paths).unwrap().status,
1186 Status::Cancelled
1187 );
1188 }
1189
1190 #[test]
1191 fn cancel_takes_the_run_lock_exactly_once() {
1192 // The single-lock honesty guarantee: the whole transaction (N node
1193 // reports + the run.status append) runs under ONE flock acquisition, not
1194 // one per appended event. Spy on `RunLock::acquire` to prove it.
1195 let tmp = TempDir::new().unwrap();
1196 let paths = fresh_run(&tmp);
1197 bootstrap(&paths, 5);
1198
1199 // Bootstrap itself takes the lock once per append; only the cancel call
1200 // is under measurement.
1201 ACQUIRE_COUNT.with(|c| c.set(0));
1202 let out = cancel_run(&paths, Some("stop")).unwrap();
1203 assert_eq!(out.nodes_cancelled.len(), 5);
1204 assert_eq!(
1205 ACQUIRE_COUNT.with(std::cell::Cell::get),
1206 1,
1207 "cancel must take the run lock exactly once, not once per node (N+1)"
1208 );
1209 }
1210
1211 #[test]
1212 fn cancel_does_not_duplicate_a_node_report_already_in_the_log() {
1213 // Crash-retry idempotency: a prior cancel appended+fsynced a node's
1214 // cancel `node.report` (carrying the deterministic key) but crashed
1215 // before folding its projection, so the node still reads live. A
1216 // re-cancel must NOT append a second logical-cancel event for it.
1217 let tmp = TempDir::new().unwrap();
1218 let paths = fresh_run(&tmp);
1219 bootstrap(&paths, 1); // run.created (seq 1) + node.created (seq 2)
1220
1221 // Durably append the cancel report WITH the deterministic key, but
1222 // without folding it — the node stays Pending (live), modeling the
1223 // fsynced-but-not-applied window.
1224 let node = nid("n-0001");
1225 let key = node_cancel_key(&paths.run_id, &node);
1226 RunLock::with_lock(&paths, |lock| {
1227 append_event_with_seq(
1228 lock,
1229 &paths,
1230 3,
1231 "node.report",
1232 Some(&node),
1233 Some(&key),
1234 json!({ "success": false, "cancelled": true, "reason": "x" }),
1235 )
1236 })
1237 .unwrap();
1238 assert_eq!(node_status(&paths, "n-0001"), Status::Pending);
1239 assert_eq!(report_count(&paths, "n-0001"), 1);
1240
1241 let out = cancel_run(&paths, None).unwrap();
1242 // The node converges (it is reported cancelled) but no duplicate report
1243 // is appended — the log still holds exactly one `node.report` for it.
1244 assert_eq!(
1245 out.nodes_cancelled
1246 .iter()
1247 .map(NodeId::as_str)
1248 .collect::<Vec<_>>(),
1249 vec!["n-0001"],
1250 );
1251 assert_eq!(
1252 report_count(&paths, "n-0001"),
1253 1,
1254 "the already-logged cancel report must not be duplicated"
1255 );
1256 // Convergence: the crash-stranded projection is folded from the
1257 // already-logged event, so the node reads Cancelled (not the stale
1258 // Pending) even though no new event was appended for it.
1259 assert_eq!(
1260 node_status(&paths, "n-0001"),
1261 Status::Cancelled,
1262 "the already-logged cancel must be re-folded, not just skipped"
1263 );
1264 assert_eq!(
1265 crate::read_manifest(&paths).unwrap().status,
1266 Status::Cancelled
1267 );
1268 }
1269
1270 #[test]
1271 fn cancel_does_not_duplicate_run_status_already_in_the_log() {
1272 // The run-status analogue: a prior cancel fsynced `run.status: cancelled`
1273 // (with its deterministic key) but crashed before folding the manifest,
1274 // so the manifest still reads non-terminal. A re-cancel must not append a
1275 // second `run.status: cancelled`.
1276 let tmp = TempDir::new().unwrap();
1277 let paths = fresh_run(&tmp);
1278 bootstrap(&paths, 0); // run.created only (seq 1)
1279
1280 let key = run_status_cancel_key(&paths.run_id);
1281 RunLock::with_lock(&paths, |lock| {
1282 append_event_with_seq(
1283 lock,
1284 &paths,
1285 2,
1286 "run.status",
1287 None,
1288 Some(&key),
1289 json!({ "status": "cancelled" }),
1290 )
1291 })
1292 .unwrap();
1293 // Manifest never folded the cancel, so it is not terminal here.
1294 assert_ne!(
1295 crate::read_manifest(&paths).unwrap().status,
1296 Status::Cancelled
1297 );
1298 let before = read_all_events(&paths.events()).unwrap().len();
1299
1300 let out = cancel_run(&paths, None).unwrap();
1301 assert!(!out.run_was_already_cancelled);
1302 assert_eq!(
1303 read_all_events(&paths.events()).unwrap().len(),
1304 before,
1305 "no duplicate run.status appended when one is already logged"
1306 );
1307 // Convergence: the manifest is folded from the already-logged
1308 // `run.status: cancelled` instead of being left stale.
1309 assert_eq!(
1310 crate::read_manifest(&paths).unwrap().status,
1311 Status::Cancelled,
1312 "the already-logged run.status must be re-folded, not just skipped"
1313 );
1314 }
1315
1316 #[test]
1317 fn cancel_skips_node_terminal_in_log_despite_stale_live_projection() {
1318 // cancel-liveness-from-log: a non-cancel terminal event (here a
1319 // `node.status` to a terminal value) was fsynced to the log but its
1320 // projection fold was crash-interrupted, so `nodes/n-0001.json` still
1321 // reads the stale live (Pending) status. The cancel must derive liveness
1322 // from the LOG and treat the node as already-terminal — never
1323 // synthesizing a cancel that would over-write the log's terminal and
1324 // diverge on a future rebuild (which replays node.status: done FIRST and
1325 // drops the later cancel).
1326 let tmp = TempDir::new().unwrap();
1327 let paths = fresh_run(&tmp);
1328 bootstrap(&paths, 2); // run.created(1) + node.created n-0001(2), n-0002(3)
1329
1330 // Raw-append (no fold) a terminal `node.status` for n-0001: the log
1331 // records it Done, but the projection stays the stale crash-window
1332 // Pending.
1333 let n1 = nid("n-0001");
1334 RunLock::with_lock(&paths, |lock| {
1335 append_event_with_seq(
1336 lock,
1337 &paths,
1338 4,
1339 "node.status",
1340 Some(&n1),
1341 None,
1342 json!({ "status": "done" }),
1343 )
1344 })
1345 .unwrap();
1346 assert_eq!(
1347 node_status(&paths, "n-0001"),
1348 Status::Pending,
1349 "projection is the stale, crash-stranded live status"
1350 );
1351
1352 let out = cancel_run(&paths, Some("stop")).unwrap();
1353 // n-0001 is settled by the log, NOT freshly cancelled; only the
1354 // genuinely live n-0002 is cancelled.
1355 assert_eq!(
1356 out.nodes_already_terminal
1357 .iter()
1358 .map(NodeId::as_str)
1359 .collect::<Vec<_>>(),
1360 vec!["n-0001"],
1361 "the log-terminal node is reported already-terminal, not cancelled"
1362 );
1363 assert_eq!(
1364 out.nodes_cancelled
1365 .iter()
1366 .map(NodeId::as_str)
1367 .collect::<Vec<_>>(),
1368 vec!["n-0002"],
1369 );
1370 // No cancel report was synthesized for n-0001: the log still holds zero
1371 // `node.report` lines for it, so a rebuild reconstructs it from the
1372 // `node.status: done` (Done), not a divergent Cancelled.
1373 assert_eq!(
1374 report_count(&paths, "n-0001"),
1375 0,
1376 "no cancel over-write was appended for the log-terminal node"
1377 );
1378 }
1379
1380 #[test]
1381 fn cancel_skips_node_with_unfolded_success_report_in_log() {
1382 // The issue's headline case: a `node.report { success: true }` fsynced
1383 // but not folded leaves a stale-live projection. Liveness from the log
1384 // settles the node as Done (already-terminal); the old projection-derived
1385 // check would have wrongly cancelled it over its already-logged success.
1386 let tmp = TempDir::new().unwrap();
1387 let paths = fresh_run(&tmp);
1388 bootstrap(&paths, 1); // run.created(1) + node.created n-0001(2)
1389 let n1 = nid("n-0001");
1390 RunLock::with_lock(&paths, |lock| {
1391 append_event_with_seq(
1392 lock,
1393 &paths,
1394 3,
1395 "node.report",
1396 Some(&n1),
1397 None,
1398 json!({ "success": true }),
1399 )
1400 })
1401 .unwrap();
1402 assert_eq!(
1403 node_status(&paths, "n-0001"),
1404 Status::Pending,
1405 "stale live projection (success report fsynced but not folded)"
1406 );
1407
1408 let out = cancel_run(&paths, None).unwrap();
1409 assert_eq!(
1410 out.nodes_already_terminal
1411 .iter()
1412 .map(NodeId::as_str)
1413 .collect::<Vec<_>>(),
1414 vec!["n-0001"],
1415 );
1416 assert!(
1417 out.nodes_cancelled.is_empty(),
1418 "a node the log shows Done must not be cancelled"
1419 );
1420 assert_eq!(
1421 report_count(&paths, "n-0001"),
1422 1,
1423 "only the original success report remains; no cancel was appended"
1424 );
1425 }
1426
1427 #[test]
1428 fn cancel_ledger_streams_large_report_payloads() {
1429 // The streaming ledger skims each line's envelope + a few small status
1430 // fields, never materializing the (here multi-KB) `node.report` `data`
1431 // payload. A node settled by such a report is still correctly seen as
1432 // terminal from the log, and a live sibling is still cancelled — proving
1433 // liveness is derived without holding whole reports in memory.
1434 let tmp = TempDir::new().unwrap();
1435 let paths = fresh_run(&tmp);
1436 bootstrap(&paths, 2);
1437 let big = "x".repeat(64 * 1024);
1438 append_and_apply_event(
1439 &paths,
1440 "node.report",
1441 Some(&nid("n-0001")),
1442 None,
1443 json!({ "success": true, "summary": big }),
1444 )
1445 .unwrap();
1446 assert_eq!(node_status(&paths, "n-0001"), Status::Done);
1447
1448 let out = cancel_run(&paths, Some("stop")).unwrap();
1449 assert_eq!(
1450 out.nodes_already_terminal
1451 .iter()
1452 .map(NodeId::as_str)
1453 .collect::<Vec<_>>(),
1454 vec!["n-0001"],
1455 );
1456 assert_eq!(
1457 out.nodes_cancelled
1458 .iter()
1459 .map(NodeId::as_str)
1460 .collect::<Vec<_>>(),
1461 vec!["n-0002"],
1462 );
1463 }
1464
1465 // --- per-node cancel (`cancel_node`) -----------------------------------
1466
1467 #[test]
1468 fn cancel_node_settles_one_node_and_leaves_the_run_and_siblings_live() {
1469 // The fan-out headline: cancelling one live child settles ONLY that node,
1470 // preserves it as Cancelled, and leaves the run + every sibling untouched
1471 // and non-terminal — the supervisor's rollup (not this call) terminalizes
1472 // the batch later.
1473 let tmp = TempDir::new().unwrap();
1474 let paths = fresh_run(&tmp);
1475 bootstrap(&paths, 3);
1476
1477 let out = cancel_node(&paths, &nid("n-0002"), Some("stuck")).unwrap();
1478 assert_eq!(out.node_id.as_str(), "n-0002");
1479 assert!(out.cancelled);
1480 assert!(!out.already_terminal);
1481 assert_eq!(out.rolled_up, None, "siblings live → run not rolled up");
1482
1483 assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
1484 assert_eq!(node_status(&paths, "n-0001"), Status::Pending);
1485 assert_eq!(node_status(&paths, "n-0003"), Status::Pending);
1486 assert!(
1487 !crate::read_manifest(&paths).unwrap().status.is_terminal(),
1488 "no run.status is appended by a per-node cancel while siblings are live"
1489 );
1490 // The synthesized report carries the branch-preserving cancel shape.
1491 let report = crate::read_node(&paths, &nid("n-0002"))
1492 .unwrap()
1493 .last_report
1494 .expect("cancel report recorded");
1495 assert_eq!(report["cancelled"], true);
1496 assert_eq!(report["success"], false);
1497 assert_eq!(report["reason"], "stuck");
1498 }
1499
1500 #[test]
1501 fn cancel_node_unknown_id_is_node_not_found() {
1502 let tmp = TempDir::new().unwrap();
1503 let paths = fresh_run(&tmp);
1504 bootstrap(&paths, 1);
1505 let err = cancel_node(&paths, &nid("n-0009"), None).unwrap_err();
1506 assert!(
1507 matches!(err, Error::NodeNotFound { ref node_id } if node_id == "n-0009"),
1508 "got {err:?}"
1509 );
1510 }
1511
1512 #[test]
1513 fn cancel_node_on_already_terminal_node_is_idempotent_noop() {
1514 // A node that finished on its own (Done) is reported already-terminal,
1515 // never freshly cancelled, and no cancel report is appended over its
1516 // success.
1517 let tmp = TempDir::new().unwrap();
1518 let paths = fresh_run(&tmp);
1519 bootstrap(&paths, 2);
1520 append_and_apply_event(
1521 &paths,
1522 "node.report",
1523 Some(&nid("n-0001")),
1524 None,
1525 json!({ "success": true }),
1526 )
1527 .unwrap();
1528
1529 let out = cancel_node(&paths, &nid("n-0001"), None).unwrap();
1530 assert!(!out.cancelled);
1531 assert!(out.already_terminal);
1532 assert_eq!(node_status(&paths, "n-0001"), Status::Done, "untouched");
1533 assert_eq!(report_count(&paths, "n-0001"), 1, "no cancel over-write");
1534 }
1535
1536 #[test]
1537 fn cancel_node_twice_does_not_duplicate_the_report() {
1538 // Idempotent duplicate per-node cancel: the second call converges/no-ops
1539 // and never appends a second cancel `node.report`.
1540 let tmp = TempDir::new().unwrap();
1541 let paths = fresh_run(&tmp);
1542 bootstrap(&paths, 2);
1543
1544 let first = cancel_node(&paths, &nid("n-0001"), Some("x")).unwrap();
1545 assert!(first.cancelled);
1546 assert_eq!(report_count(&paths, "n-0001"), 1);
1547
1548 let second = cancel_node(&paths, &nid("n-0001"), Some("x")).unwrap();
1549 assert!(!second.cancelled);
1550 assert!(second.already_terminal);
1551 assert_eq!(
1552 report_count(&paths, "n-0001"),
1553 1,
1554 "a duplicate per-node cancel must not append a second report"
1555 );
1556 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
1557 }
1558
1559 #[test]
1560 fn cancel_node_converges_a_crash_stranded_prior_cancel_without_duplicating() {
1561 // Crash-retry: a prior cancel fsynced the node's cancel `node.report`
1562 // (with the deterministic key) but crashed before folding the projection,
1563 // so the node still reads live. A re-cancel re-folds the logged event
1564 // (node → Cancelled) without appending a second report.
1565 let tmp = TempDir::new().unwrap();
1566 let paths = fresh_run(&tmp);
1567 bootstrap(&paths, 1); // run.created(1) + node.created(2)
1568 let node = nid("n-0001");
1569 let key = node_cancel_key(&paths.run_id, &node);
1570 RunLock::with_lock(&paths, |lock| {
1571 append_event_with_seq(
1572 lock,
1573 &paths,
1574 3,
1575 "node.report",
1576 Some(&node),
1577 Some(&key),
1578 json!({ "success": false, "cancelled": true, "reason": "x" }),
1579 )
1580 })
1581 .unwrap();
1582 assert_eq!(node_status(&paths, "n-0001"), Status::Pending);
1583
1584 let out = cancel_node(&paths, &node, None).unwrap();
1585 assert!(out.cancelled, "the stranded cancel is converged");
1586 assert!(!out.already_terminal);
1587 assert_eq!(report_count(&paths, "n-0001"), 1, "no duplicate append");
1588 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
1589 }
1590
1591 #[test]
1592 fn cancel_node_resolves_a_node_with_a_missing_projection() {
1593 // The log — not the `nodes/*.json` scan — is authoritative for the node
1594 // set: a node whose projection write was crash-interrupted is still
1595 // cancellable (and its cancel report lands so a rebuild reconstructs it
1596 // Cancelled, not live).
1597 let tmp = TempDir::new().unwrap();
1598 let paths = fresh_run(&tmp);
1599 bootstrap(&paths, 2);
1600 let n2 = nid("n-0002");
1601 std::fs::remove_file(paths.node(&n2)).unwrap();
1602 assert!(read_node_opt(&paths, &n2).unwrap().is_none());
1603
1604 let out = cancel_node(&paths, &n2, Some("stop")).unwrap();
1605 assert!(out.cancelled);
1606 // The source-of-truth log now carries the terminal cancel report, so a
1607 // rebuild reconstructs the node Cancelled (its projection stays absent —
1608 // the reducer folds a report without resurrecting a deleted projection,
1609 // exactly as the whole-run cancel does).
1610 assert_eq!(report_count(&paths, "n-0002"), 1);
1611 }
1612
1613 #[test]
1614 fn cancel_node_blank_note_falls_back_to_default_reason() {
1615 let tmp = TempDir::new().unwrap();
1616 let paths = fresh_run(&tmp);
1617 bootstrap(&paths, 1);
1618 let out = cancel_node(&paths, &nid("n-0001"), Some(" ")).unwrap();
1619 assert!(out.cancelled);
1620 let report = crate::read_node(&paths, &nid("n-0001"))
1621 .unwrap()
1622 .last_report
1623 .expect("cancel report recorded");
1624 assert_eq!(report["reason"], "cancelled by user");
1625 }
1626
1627 #[test]
1628 fn cancel_node_takes_the_run_lock_exactly_once() {
1629 let tmp = TempDir::new().unwrap();
1630 let paths = fresh_run(&tmp);
1631 bootstrap(&paths, 3);
1632 ACQUIRE_COUNT.with(|c| c.set(0));
1633 let out = cancel_node(&paths, &nid("n-0002"), Some("x")).unwrap();
1634 assert!(out.cancelled);
1635 assert_eq!(
1636 ACQUIRE_COUNT.with(std::cell::Cell::get),
1637 1,
1638 "per-node cancel must take the run lock exactly once"
1639 );
1640 }
1641
1642 #[test]
1643 fn cancel_last_live_node_rolls_the_run_up_under_the_same_lock() {
1644 // Cancelling the final live node terminalizes the run HERE (llm-review
1645 // C1) — not deferred to a possibly-dead supervisor. Every node cancelled,
1646 // none failed → the run rolls up to Cancelled in the same transaction.
1647 let tmp = TempDir::new().unwrap();
1648 let paths = fresh_run(&tmp);
1649 bootstrap(&paths, 2);
1650
1651 // First cancel: n-0002 still live → run stays live.
1652 let first = cancel_node(&paths, &nid("n-0001"), Some("x")).unwrap();
1653 assert!(first.cancelled);
1654 assert_eq!(first.rolled_up, None);
1655 assert!(!crate::read_manifest(&paths).unwrap().status.is_terminal());
1656
1657 // Second cancel settles the last live node → the run rolls up.
1658 let out = cancel_node(&paths, &nid("n-0002"), Some("x")).unwrap();
1659 assert!(out.cancelled);
1660 assert_eq!(out.rolled_up, Some(Status::Cancelled));
1661 assert_eq!(node_status(&paths, "n-0001"), Status::Cancelled);
1662 assert_eq!(node_status(&paths, "n-0002"), Status::Cancelled);
1663 assert_eq!(
1664 crate::read_manifest(&paths).unwrap().status,
1665 Status::Cancelled,
1666 "the last per-node cancel terminalizes the run itself"
1667 );
1668 }
1669
1670 #[test]
1671 fn cancel_last_live_node_rolls_up_to_failed_when_a_sibling_failed() {
1672 // A genuine failure dominates the roll-up: cancelling the last live node
1673 // of a batch where a sibling already failed rolls the run up to Failed
1674 // (not Cancelled).
1675 let tmp = TempDir::new().unwrap();
1676 let paths = fresh_run(&tmp);
1677 bootstrap(&paths, 2);
1678 append_and_apply_event(
1679 &paths,
1680 "node.report",
1681 Some(&nid("n-0001")),
1682 None,
1683 json!({ "success": false }),
1684 )
1685 .unwrap();
1686 assert_eq!(node_status(&paths, "n-0001"), Status::Failed);
1687
1688 let out = cancel_node(&paths, &nid("n-0002"), Some("x")).unwrap();
1689 assert!(out.cancelled);
1690 assert_eq!(out.rolled_up, Some(Status::Failed));
1691 assert_eq!(crate::read_manifest(&paths).unwrap().status, Status::Failed);
1692 }
1693
1694 #[test]
1695 fn cancel_last_live_node_rolls_up_to_cancelled_on_done_plus_cancelled_mix() {
1696 // Some siblings merged (Done), the last is cancelled, none failed → the
1697 // batch rolls up to Cancelled (nothing failed, not a clean all-Done).
1698 let tmp = TempDir::new().unwrap();
1699 let paths = fresh_run(&tmp);
1700 bootstrap(&paths, 2);
1701 append_and_apply_event(
1702 &paths,
1703 "node.report",
1704 Some(&nid("n-0001")),
1705 None,
1706 json!({ "success": true }),
1707 )
1708 .unwrap();
1709 assert_eq!(node_status(&paths, "n-0001"), Status::Done);
1710
1711 let out = cancel_node(&paths, &nid("n-0002"), Some("x")).unwrap();
1712 assert!(out.cancelled);
1713 assert_eq!(out.rolled_up, Some(Status::Cancelled));
1714 assert_eq!(
1715 crate::read_manifest(&paths).unwrap().status,
1716 Status::Cancelled
1717 );
1718 }
1719
1720 #[test]
1721 fn cancel_node_refuses_a_done_run() {
1722 // Mirror `cancel_run`'s guard (llm-review C4): a Done/Failed run is
1723 // refused rather than appending a dead post-terminal cancel.
1724 let tmp = TempDir::new().unwrap();
1725 let paths = fresh_run(&tmp);
1726 bootstrap(&paths, 1);
1727 append_and_apply_event(
1728 &paths,
1729 "node.report",
1730 Some(&nid("n-0001")),
1731 None,
1732 json!({ "success": true }),
1733 )
1734 .unwrap();
1735 append_and_apply_event(
1736 &paths,
1737 "run.status",
1738 None,
1739 None,
1740 json!({ "status": "done" }),
1741 )
1742 .unwrap();
1743 let before = read_all_events(&paths.events()).unwrap().len();
1744
1745 let err = cancel_node(&paths, &nid("n-0001"), None).unwrap_err();
1746 assert!(
1747 matches!(
1748 err,
1749 Error::RunAlreadyTerminal {
1750 status: Status::Done
1751 }
1752 ),
1753 "got {err:?}"
1754 );
1755 assert_eq!(
1756 read_all_events(&paths.events()).unwrap().len(),
1757 before,
1758 "a refused per-node cancel must not append any event"
1759 );
1760 }
1761
1762 #[test]
1763 fn cancel_node_last_node_shares_run_status_key_with_whole_run_cancel() {
1764 // The last-node roll-up uses the whole-run cancel's `run-status` key, so a
1765 // later `cancel_run` converges on the SAME logical run.status rather than
1766 // appending a duplicate.
1767 let tmp = TempDir::new().unwrap();
1768 let paths = fresh_run(&tmp);
1769 bootstrap(&paths, 1);
1770 let out = cancel_node(&paths, &nid("n-0001"), Some("x")).unwrap();
1771 assert_eq!(out.rolled_up, Some(Status::Cancelled));
1772 let run_status_events = read_all_events(&paths.events())
1773 .unwrap()
1774 .into_iter()
1775 .filter(|e| e.kind == "run.status")
1776 .count();
1777 assert_eq!(run_status_events, 1);
1778
1779 // A whole-run cancel now finds the run already cancelled and converges —
1780 // no second run.status.
1781 let cr = cancel_run(&paths, Some("x")).unwrap();
1782 assert!(cr.run_was_already_cancelled);
1783 let run_status_events = read_all_events(&paths.events())
1784 .unwrap()
1785 .into_iter()
1786 .filter(|e| e.kind == "run.status")
1787 .count();
1788 assert_eq!(
1789 run_status_events, 1,
1790 "cancel_run must not duplicate the run.status the last-node roll-up wrote"
1791 );
1792 }
1793}