Skip to main content

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}