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