Skip to main content

octl_core/
reducer.rs

1//! Event → projection reducer (design.md §1.4).
2//!
3//! Each event mutates zero or more projection files. Unknown kinds are
4//! ignored for forward compatibility. The reducer expects to run under the
5//! per-run `flock`.
6//!
7//! Idempotency contract (per design.md §7.3 at-least-once delivery):
8//! `*.created` reducers short-circuit when their projection file already
9//! exists; status/resolution reducers are no-ops once the terminal state
10//! has been reached. Replaying the same event stream against existing
11//! projections is therefore a clean no-op-or-apply.
12//!
13//! This idempotence is load-bearing for the `applied_seq` watermark
14//! (append-then-apply atomicity; see [`crate::schema::Manifest::applied_seq`]
15//! and [`crate::events::append_and_apply_event`]). Of the two options the spec
16//! offered — make the reducer idempotent, OR have the writer skip events
17//! already reflected in the projection — we chose **idempotent reducer**: the
18//! existence/terminal guards already present here mean the catch-up replay can
19//! re-fold *any* tail event (one whose projection landed before a crash, or one
20//! whose projection did not) with the same no-op-or-apply outcome, so the
21//! writer needs no per-event "already applied?" probe. The watermark advances
22//! only after an event's projections are fsynced, so it can lag the projections
23//! but never lead them.
24//!
25//! ## Manifest counters are derived, not folded
26//!
27//! The reducers here deliberately do **not** touch the manifest's denormalized
28//! `node_count` counter. It is
29//! recomputed from the projection directories by
30//! [`derive_counters`](crate::projections), invoked from
31//! [`advance_applied_seq`](crate::events) at the
32//! watermark advance. An earlier design incremented/decremented them inside
33//! these reducers, but a crash between a projection write and the follow-on
34//! `manifest.json` write could permanently desync them: the replay re-folded
35//! the event, hit the `*.created`/terminal idempotency guard above, and skipped
36//! the counter mutation that never actually landed. Deriving the counts makes
37//! drift impossible — there is no delta to lose. See issue
38//! `manifest-counter-desync`. A count-affecting reducer still emits its manifest
39//! op to refresh `updated_at`; the counter fields it carries are overwritten by
40//! the derive step.
41//!
42//! Because a count-affecting event rewrites a projection file *and* the
43//! manifest counter under the same exclusive `flock`, a reader that scans both
44//! together must hold the shared `flock` (`LOCK_SH`) for the whole scan or it
45//! could see the projection change without the matching counter (or vice
46//! versa). See [`crate::projections`] and design.md §4.
47
48use std::path::{Path, PathBuf};
49
50use chrono::{DateTime, Utc};
51use serde_json::Value;
52
53use crate::error::{Error, Result};
54use crate::paths::RunPaths;
55use crate::projections::{read_manifest_opt, read_node_opt, write_manifest, write_node};
56use crate::report::ReportOrigin;
57use crate::schema::{
58    ChildRef, Event, IdValidationError, Kind, Lifecycle, Manifest, MergeTxn, Node, NodeId, RunId,
59    Status, TmuxIdentity, WorkerExit, STATE_SCHEMA_VERSION,
60};
61
62/// Map an id-validation failure on an event-sourced id to a [`CorruptEventLog`]
63/// error. An id that fails to parse here came off `events.jsonl` (or a forged
64/// event), so the log — not the caller — is the corrupt party.
65///
66/// [`CorruptEventLog`]: Error::CorruptEventLog
67fn corrupt_id(events_path: &Path, ev: &Event, e: &IdValidationError) -> Error {
68    Error::CorruptEventLog {
69        path: events_path.to_path_buf(),
70        reason: format!("event seq={} kind={}: {e}", ev.seq, ev.kind),
71    }
72}
73
74/// Parse an optional `RunId` from event-data field `field`: missing/null →
75/// `None`; a JSON string → validated `Some(RunId)`; a malformed id or a
76/// non-string value → [`Error::CorruptEventLog`].
77fn opt_run_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<RunId>> {
78    match d.get(field) {
79        None | Some(Value::Null) => Ok(None),
80        Some(Value::String(s)) => RunId::parse_str(s)
81            .map(Some)
82            .map_err(|e| corrupt_id(events_path, ev, &e)),
83        Some(_) => Err(Error::CorruptEventLog {
84            path: events_path.to_path_buf(),
85            reason: format!(
86                "event seq={} kind={} `{field}` must be a JSON string or null",
87                ev.seq, ev.kind
88            ),
89        }),
90    }
91}
92
93/// Parse an optional `NodeId` from event-data field `field`. See [`opt_run_id`].
94fn opt_node_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<NodeId>> {
95    match d.get(field) {
96        None | Some(Value::Null) => Ok(None),
97        Some(Value::String(s)) => NodeId::parse_str(s)
98            .map(Some)
99            .map_err(|e| corrupt_id(events_path, ev, &e)),
100        Some(_) => Err(Error::CorruptEventLog {
101            path: events_path.to_path_buf(),
102            reason: format!(
103                "event seq={} kind={} `{field}` must be a JSON string or null",
104                ev.seq, ev.kind
105            ),
106        }),
107    }
108}
109
110/// Parse a `kind` value from event `data` for a NEW append, failing closed on
111/// anything not a live, creatable kind.
112///
113/// `Kind`'s `#[serde(other)]` catch-all means every unrecognized string —
114/// a removed kind (`code`, `orchestrate`, …), a typo, or a future kind —
115/// deserializes to [`Kind::Unknown`] rather than erroring. That read-only
116/// catch-all exists so `run list` / `doctor` can decode a legacy on-disk run
117/// (ADR §D7); it must NOT let a garbage `kind` slip through the append gate as
118/// though it were valid. Mapping `Unknown` back to `None` keeps the reducer's
119/// `run.created` / `node.created` / `child.spawned` validation fail-closed, as
120/// it was before the 0.2 cut added the catch-all. (Legacy runs are never
121/// re-created through this path — their manifest/nodes already exist on disk and
122/// are read directly, not replayed from a fresh `*.created`.)
123fn data_kind(v: &Value) -> Option<Kind> {
124    match serde_json::from_value::<Kind>(v.clone()) {
125        Ok(Kind::Unknown) | Err(_) => None,
126        Ok(k) => Some(k),
127    }
128}
129
130fn data_status(v: &Value) -> Option<Status> {
131    serde_json::from_value(v.clone()).ok()
132}
133
134fn require_status(ev: &Event, path: PathBuf) -> Result<Status> {
135    data_status(ev.data.get("status").unwrap_or(&Value::Null)).ok_or_else(|| {
136        Error::CorruptEventLog {
137            path,
138            reason: format!("{} missing/invalid `status`", ev.kind),
139        }
140    })
141}
142
143fn want_str<'a>(events_path: &Path, ev: &Event, d: &'a Value, field: &str) -> Result<&'a str> {
144    d.get(field)
145        .and_then(Value::as_str)
146        .ok_or_else(|| Error::CorruptEventLog {
147            path: events_path.to_path_buf(),
148            reason: format!(
149                "event seq={} kind={} missing `{field}` string field",
150                ev.seq, ev.kind
151            ),
152        })
153}
154
155/// Read an optional boolean field with strict typing: missing/null → `None`,
156/// JSON bool → `Some(b)`, anything else → `CorruptEventLog`. Mirrors
157/// [`optional_str`] / [`optional_i32`]; prevents a non-boolean `success` /
158/// `cancelled` from being silently coerced to `false` and bypassing the
159/// success-XOR-cancelled invariant.
160fn optional_bool(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<bool>> {
161    match d.get(field) {
162        None | Some(Value::Null) => Ok(None),
163        Some(Value::Bool(b)) => Ok(Some(*b)),
164        Some(_) => Err(Error::CorruptEventLog {
165            path: events_path.to_path_buf(),
166            reason: format!(
167                "event seq={} kind={} `{field}` must be a JSON boolean or null",
168                ev.seq, ev.kind
169            ),
170        }),
171    }
172}
173
174fn optional_i32(d: &Value, field: &str, events_path: &Path, ev: &Event) -> Result<Option<i32>> {
175    match d.get(field) {
176        None | Some(Value::Null) => Ok(None),
177        Some(v) => {
178            let raw = v.as_i64().ok_or_else(|| Error::CorruptEventLog {
179                path: events_path.to_path_buf(),
180                reason: format!(
181                    "event seq={} kind={} `{field}` must be integer",
182                    ev.seq, ev.kind
183                ),
184            })?;
185            i32::try_from(raw)
186                .map(Some)
187                .map_err(|_| Error::CorruptEventLog {
188                    path: events_path.to_path_buf(),
189                    reason: format!(
190                        "event seq={} kind={} `{field}` out of i32 range: {raw}",
191                        ev.seq, ev.kind
192                    ),
193                })
194        }
195    }
196}
197
198fn optional_ts(
199    d: &Value,
200    field: &str,
201    events_path: &Path,
202    ev: &Event,
203) -> Result<Option<DateTime<Utc>>> {
204    match d.get(field) {
205        None | Some(Value::Null) => Ok(None),
206        Some(Value::String(s)) => DateTime::parse_from_rfc3339(s)
207            .map(|dt| Some(dt.with_timezone(&Utc)))
208            .map_err(|_| Error::CorruptEventLog {
209                path: events_path.to_path_buf(),
210                reason: format!(
211                    "event seq={} kind={} `{field}` not RFC3339",
212                    ev.seq, ev.kind
213                ),
214            }),
215        Some(_) => Err(Error::CorruptEventLog {
216            path: events_path.to_path_buf(),
217            reason: format!(
218                "event seq={} kind={} `{field}` must be RFC3339 string or null",
219                ev.seq, ev.kind
220            ),
221        }),
222    }
223}
224
225/// A projection write planned by [`reduce_event_to_ops`] and performed by
226/// [`commit_ops`].
227///
228/// Splitting the reducer into a pure *plan* phase (compute these ops from the
229/// current projection state, validating as it goes) and a *commit* phase
230/// (write them) means a single branch per kind implements both the pre-append
231/// validation gate and the post-append apply — there is no validate/apply
232/// mirror to drift out of lockstep, and the projection state is read once
233/// rather than twice.
234pub(crate) enum ProjectionOp {
235    /// Write the run manifest.
236    Manifest(Manifest),
237    /// Write a node projection.
238    Node(Node),
239}
240
241/// Commit a planned batch of projection writes, in order.
242///
243/// Caller must hold the run's [`crate::lock::RunLock`]. Pairs with
244/// [`reduce_event_to_ops`]: the ops were computed against the same locked
245/// state, and nothing mutates the projections between the plan and this commit
246/// (in the append path only `events.jsonl` is written in between), so the
247/// planned writes are still valid.
248pub(crate) fn commit_ops(paths: &RunPaths, ops: Vec<ProjectionOp>) -> Result<()> {
249    for op in ops {
250        match op {
251            ProjectionOp::Manifest(m) => write_manifest(paths, &m)?,
252            ProjectionOp::Node(n) => write_node(paths, &n)?,
253        }
254    }
255    Ok(())
256}
257
258/// Plan the projection writes one event implies, *without* performing them.
259///
260/// This is the single source of truth for both validation and application: it
261/// reads the current projection state, enforces every event-payload invariant
262/// (returning [`Error::CorruptEventLog`] for a malformed or cross-run event),
263/// and returns the exact [`ProjectionOp`]s to commit (empty for a no-op or an
264/// unknown `kind`). Because it never writes, it is also the transactional gate
265/// run *before* the durable append in
266/// [`crate::events::append_and_apply_unlocked`]: a reducer-rejected event is
267/// caught here and never reaches `events.jsonl`, so a later replay /
268/// `rebuild_projections` can't trip over a poison line. The state-dependent
269/// no-op guards live here too (a settled node/run/discussion swallows a late
270/// or even malformed event as a clean no-op rather than erroring).
271///
272/// Caller must hold the run's [`crate::lock::RunLock`].
273pub(crate) fn reduce_event_to_ops(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
274    // An event whose envelope `run_id` doesn't match the run we're folding it
275    // into means the log was copied/misrouted — folding it would silently
276    // cross-contaminate projections. Reject before planning anything.
277    if ev.run_id != paths.run_id {
278        return Err(Error::CorruptEventLog {
279            path: paths.events(),
280            reason: format!(
281                "event seq={} envelope run_id {:?} does not match run {:?}",
282                ev.seq,
283                ev.run_id.as_str(),
284                paths.run_id.as_str()
285            ),
286        });
287    }
288    // Each event kind is listed explicitly as documentation of the known set;
289    // `supervisor.exited` and the `_` fallthrough share a body intentionally.
290    #[allow(clippy::match_same_arms)]
291    match ev.kind.as_str() {
292        "run.created" => reduce_run_created(paths, ev),
293        "run.status" => reduce_run_status(paths, ev),
294        "node.created" => reduce_node_created(paths, ev),
295        "node.status" => reduce_node_status(paths, ev),
296        "node.report" => reduce_node_report(paths, ev),
297        "node.retry" => reduce_node_retry(paths, ev),
298        "worker.exited" => reduce_worker_exited(paths, ev),
299        "node.death_observed" => reduce_node_death_observed(paths, ev),
300        "node.awaiting_input" => reduce_node_awaiting_input(paths, ev),
301        "node.input_resolved" => reduce_node_input_resolved(paths, ev),
302        KIND_MERGE_STARTED => reduce_merge_started(paths, ev),
303        KIND_MERGE_ABORTED => reduce_merge_aborted(paths, ev),
304        "child.spawned" => reduce_child_spawned(paths, ev),
305        "supervisor.attached" => reduce_supervisor_attached(paths, ev),
306        "supervisor.cursor_advanced" => reduce_supervisor_cursor_advanced(paths, ev),
307        "supervisor.exited" => Ok(vec![]),
308        // Append-only audit records from `/orchestrate` (decision log +
309        // pakkopysäytys). They mutate no projection — the event log is their
310        // canonical home — so they fold to a clean no-op. Listed explicitly
311        // (rather than relying on the `_` fallthrough) so the append path's
312        // transactional gate runs the same no-op plan for them and the intent
313        // is documented at the match site. They are NOT `node.report`, so the
314        // supervisor never mistakes them for a terminal signal.
315        "orchestrator.decision" | "discuss.critical" => Ok(vec![]),
316        // At-most-once marker the supervisor appends the first time a run is
317        // observed terminal, gating the `run create --notify` completion hook so
318        // a restart never re-fires it (issue `no-completion-notification-to-parent`).
319        // Mutates no projection — the event log is its only home — so it folds to
320        // a clean no-op. Listed explicitly so the append path's transactional gate
321        // runs the same no-op plan and the intent is documented here.
322        "run.notified" | "run.awaiting_input_notified" => Ok(vec![]),
323        // Best-effort teardown audit records from the supervisor's cleanup
324        // path. Each mutates no projection — the event log is their only home —
325        // so they fold to a clean no-op. Listed explicitly so the append path's
326        // transactional gate runs the same no-op plan and the intent is
327        // documented here.
328        //   - `cleanup.window_missing`: the node's tmux window could not be
329        //     located to close it (typically a manually-resolved rebase renamed
330        //     the window — issue `worktree-merge-orphans-tmux-window`).
331        //   - `cleanup.worktree_missing`: the worktree dir was already gone at
332        //     teardown (e.g. removed manually), so nothing to `worktree remove`.
333        //   - `cleanup.branch_remove_failed`: `git branch -{d,D}` refused (e.g.
334        //     unmerged commits, or the branch is already gone); the run completes
335        //     anyway (issue `supervisor-worktree-remove-no-force`).
336        //   - `cleanup.branch_preserved`: a BLOCKED terminal report
337        //     (`success: false`, no explicit merge) intentionally left the branch
338        //     and worktree in place for the human to pick up, instead of tearing
339        //     them down (issue `blocked-report-deletes-branch`).
340        //   - `cleanup.session_killed`: the run's managed `--headless` tmux
341        //     session was torn down once its last managed window was gone, so an
342        //     empty session is not left behind (issue
343        //     `headless-tmux-session-not-torn-down`).
344        //   - `cleanup.session_retained`: the same teardown was skipped because a
345        //     human had attached to the session — never yanked out from under
346        //     them.
347        "cleanup.window_missing"
348        | "cleanup.worktree_missing"
349        | "cleanup.branch_remove_failed"
350        | "cleanup.branch_preserved"
351        | "cleanup.session_killed"
352        | "cleanup.session_retained" => Ok(vec![]),
353        // Data-integrity audit record: the supervisor found a persisted child
354        // run id (in `supervisor.state.json`'s `spawned_children`) that fails
355        // `RunId` structural validation and quarantined it — a corrupt id that
356        // would otherwise resolve with `.ok()` and be silently skipped every
357        // tick, indistinguishable from a child that completed and was torn down
358        // (issue `wildly-glorious-food`). It mutates no projection — the event
359        // log is its only home — so it folds to a clean no-op. Listed
360        // explicitly so the append path's transactional gate runs the same
361        // no-op plan and the intent is documented here.
362        "supervisor.child_id_quarantined" => Ok(vec![]),
363        _ => Ok(vec![]),
364    }
365}
366
367/// The projection file [`commit_ops`] writes for `op`, keyed exactly as the
368/// `write_*` helpers key it internally. Shared by [`plan_projections`] (which
369/// reports the path) and conceptually by [`commit_ops`] (which writes it), so
370/// the enumerated path list can never name a different file than the one the
371/// reducer actually fsyncs.
372fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
373    match op {
374        ProjectionOp::Manifest(_) => paths.manifest(),
375        ProjectionOp::Node(n) => paths.node(&n.node_id),
376    }
377}
378
379/// Enumerate the projection files the reducer would write for `event`, in the
380/// order `commit_ops` would write them, *without* performing any write.
381///
382/// This is the single source of truth that ends the CLI/reducer divergence the
383/// `projected-paths-into-reducer` issue describes: rather than a hand-maintained
384/// list in `octl-cli` that drifts whenever a new projection is added, both the
385/// reducer and a caller's preflight (`event create --dry-run`) read the *same*
386/// `reduce_event_to_ops` plan. This function maps that plan to file paths;
387/// `apply_event` commits it. A new projection added to a reducer arm is
388/// therefore reflected here automatically.
389///
390/// Because it runs the real reducer plan against current projection state, the
391/// result is exact, not a guess: a state-dependent no-op (a settled node, an
392/// already-created projection, a terminal-guarded transition) yields an empty
393/// list — precisely the files `apply_event` would touch, which is none. A
394/// malformed-payload event surfaces the same [`Error::CorruptEventLog`] the
395/// real apply would, so a dry-run preflight cannot report success for an event
396/// the write path would reject.
397///
398/// Caller should hold the run's [`crate::lock::RunLock`] for a snapshot
399/// consistent with a concurrent reducer; a lock-free read is best-effort.
400pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
401    let ops = reduce_event_to_ops(paths, event)?;
402    Ok(ops.iter().map(|op| op_path(paths, op)).collect())
403}
404
405/// Apply one event to projections: plan via [`reduce_event_to_ops`], then
406/// [`commit_ops`]. No-op for unknown `kind`. Caller must hold the run's
407/// [`crate::lock::RunLock`].
408///
409/// Shares the one [`reduce_event_to_ops`] plan with [`plan_projections`]: the
410/// paths that function reports are exactly the files this one fsyncs, because
411/// both consume the same `ProjectionOp` vector (this commits it; that maps it to
412/// paths via [`op_path`]).
413///
414/// `pub(crate)`: applying an event in isolation (without the matching
415/// `events.jsonl` append) is an internal building block used by `cancel` (to
416/// re-fold a crash-stranded event) and a future `rebuild_projections_from_events`.
417/// External callers mutate state through
418/// [`crate::events::append_and_apply_event`] so the log and projections can
419/// never diverge.
420pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
421    let ops = reduce_event_to_ops(paths, ev)?;
422    commit_ops(paths, ops)
423}
424
425/// Validate an event WITHOUT writing anything — [`reduce_event_to_ops`] with
426/// the planned writes discarded. Returns `Err` in exactly the cases
427/// [`apply_event`] would (they share the one plan), so a dry-run check can
428/// never drift from the apply.
429///
430/// `#[cfg(test)]`: the append path validates by inspecting
431/// `reduce_event_to_ops` directly (it needs the planned ops anyway), so this
432/// discard-the-ops wrapper exists only for the reducer's agreement tests.
433#[cfg(test)]
434pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
435    reduce_event_to_ops(paths, ev).map(|_| ())
436}
437
438/// The envelope `node_id` that a `node.*` event must carry, with the same
439/// `CorruptEventLog` message `apply_*` produces. Shared by validate/apply so
440/// the missing-id check can't drift between them.
441fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
442    ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
443        path: events_path.to_path_buf(),
444        reason: format!(
445            "event seq={} kind={} missing top-level `node_id`",
446            ev.seq, ev.kind
447        ),
448    })
449}
450
451fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
452    // Idempotent: a replayed `run.created` against an existing manifest is a
453    // no-op (but validates that `run_id` matches; otherwise the event log
454    // is being applied to the wrong run).
455    if let Some(existing) = read_manifest_opt(paths)? {
456        if existing.run_id != ev.run_id {
457            return Err(Error::CorruptEventLog {
458                path: paths.manifest(),
459                reason: format!(
460                    "run.created run_id={} conflicts with existing manifest run_id={}",
461                    ev.run_id, existing.run_id
462                ),
463            });
464        }
465        return Ok(vec![]);
466    }
467    let events_path = paths.events();
468    let d = &ev.data;
469    let kind =
470        data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
471            path: events_path.clone(),
472            reason: "run.created missing/invalid `kind`".into(),
473        })?;
474    let lifecycle: Lifecycle = serde_json::from_value(
475        d.get("lifecycle").cloned().unwrap_or(Value::Null),
476    )
477    .map_err(|_| Error::CorruptEventLog {
478        path: events_path.clone(),
479        reason: "run.created missing/invalid `lifecycle`".into(),
480    })?;
481    let title = want_str(&events_path, ev, d, "title")?.to_string();
482    let m = Manifest {
483        schema_version: STATE_SCHEMA_VERSION,
484        // Created at the watermark floor; the append path advances it to this
485        // event's `seq` (after the manifest is fsynced) in `advance_applied_seq`.
486        applied_seq: 0,
487        // `run_id == paths.run_id` was verified at `reduce_event_to_ops` entry.
488        run_id: paths.run_id.clone(),
489        kind,
490        lifecycle,
491        title,
492        status: Status::Pending,
493        created_at: ev.ts,
494        updated_at: ev.ts,
495        source_repo: d
496            .get("source_repo")
497            .and_then(Value::as_str)
498            .map(str::to_string),
499        source_branch: d
500            .get("source_branch")
501            .and_then(Value::as_str)
502            .map(str::to_string),
503        worktree_root: d
504            .get("worktree_root")
505            .and_then(Value::as_str)
506            .map(str::to_string),
507        managed_tmux_session: d
508            .get("managed_tmux_session")
509            .and_then(Value::as_str)
510            .map(str::to_string),
511        notify_cmd: d
512            .get("notify_cmd")
513            .and_then(Value::as_str)
514            .map(str::to_string),
515        harness: d.get("harness").and_then(Value::as_str).map(str::to_string),
516        node_count: 0,
517        parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
518        parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
519    };
520    Ok(vec![ProjectionOp::Manifest(m)])
521}
522
523fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
524    let mut m = match read_manifest_opt(paths)? {
525        Some(m) => m,
526        None => return Ok(vec![]),
527    };
528    let new_status = require_status(ev, paths.events())?;
529    // Terminal-state guard: a settled run never transitions again (e.g. a
530    // late `run.status running` after a cancel). See run-cli-read/handoff.md D5.
531    if m.status.is_terminal() {
532        trace_terminal_noop(ev, m.status, new_status);
533        return Ok(vec![]);
534    }
535    if m.status == new_status {
536        return Ok(vec![]);
537    }
538    m.status = new_status;
539    m.updated_at = ev.ts;
540    Ok(vec![ProjectionOp::Manifest(m)])
541}
542
543/// Reconstruct the fully-qualified tmux identity from `node.created` event
544/// data. Returns `Some` only when both `tmux_session` and `tmux_window_id` are
545/// present and non-empty — the minimum needed to match a window. `tmux_socket`
546/// is optional (a default-socket spawn may emit null); an empty socket is
547/// normalized to `None` so the watchdog never invokes `tmux -S ""`.
548/// `tmux_pane_id` is likewise optional (create.sh predating it emits nothing);
549/// agent-log capture falls back to the window's active pane when absent. Legacy
550/// events from a create.sh that predates the qualified fields (or that emit a
551/// partial/empty identity) yield `None`, so the node falls back to bare-name
552/// matching on `tmux_window`.
553fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
554    let nonempty = |key| {
555        d.get(key)
556            .and_then(Value::as_str)
557            .map(str::trim)
558            .filter(|s| !s.is_empty())
559            .map(str::to_string)
560    };
561    let session = nonempty("tmux_session")?;
562    let window_id = nonempty("tmux_window_id")?;
563    Some(TmuxIdentity {
564        socket: nonempty("tmux_socket"),
565        session,
566        window_id,
567        // Optional: create.sh predating the field (or a failed pane query)
568        // emits no `tmux_pane_id`; capture then falls back to `window_id`.
569        pane_id: nonempty("tmux_pane_id"),
570    })
571}
572
573fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
574    let events_path = paths.events();
575    // The envelope `node_id` is already a validated `NodeId` (parsed on read),
576    // so take it directly — no re-parse needed.
577    let node_id = require_envelope_node_id(&events_path, ev)?;
578    let is_default_node = node_id.as_str() == "n-0001";
579    // Idempotent on replay: skip if the node already exists.
580    if read_node_opt(paths, &node_id)?.is_some() {
581        return Ok(vec![]);
582    }
583    let d = &ev.data;
584    let kind =
585        data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
586            path: events_path.clone(),
587            reason: format!(
588                "event seq={} kind=node.created missing/invalid `kind`",
589                ev.seq
590            ),
591        })?;
592    let n = Node {
593        schema_version: STATE_SCHEMA_VERSION,
594        node_id,
595        // `run_id == paths.run_id` was verified at `reduce_event_to_ops` entry.
596        run_id: paths.run_id.clone(),
597        parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
598        kind,
599        status: Status::Pending,
600        task: d.get("task").and_then(Value::as_str).map(str::to_string),
601        worktree_path: d
602            .get("worktree_path")
603            .and_then(Value::as_str)
604            .map(str::to_string),
605        branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
606        base_sha: d
607            .get("base_sha")
608            .and_then(Value::as_str)
609            .filter(|s| !s.is_empty())
610            .map(str::to_string),
611        tmux_window: d
612            .get("tmux_window")
613            .and_then(Value::as_str)
614            .map(str::to_string),
615        tmux_identity: tmux_identity_from_data(d),
616        agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
617        agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
618        supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
619        children: Vec::new(),
620        started_at: Some(ev.ts),
621        updated_at: ev.ts,
622        last_report: None,
623        last_processed_report_seq_by_child: serde_json::Map::default(),
624        retry_attempts: 0,
625        worker_exit: None,
626        pending_merge: None,
627        first_death_at: None,
628        awaiting_input: None,
629    };
630    let mut ops = vec![ProjectionOp::Node(n)];
631    if let Some(mut m) = read_manifest_opt(paths)? {
632        // Materialization is the point at which an implicit source branch is
633        // known. Preserve an explicit run.created value; otherwise fold the
634        // source discovered by the creator into the manifest in this same
635        // locked event application.
636        if is_default_node && m.source_branch.is_none() {
637            m.source_branch = d
638                .get("source_branch")
639                .and_then(Value::as_str)
640                .filter(|branch| !branch.is_empty())
641                .map(str::to_string);
642        }
643        // `node_count` is derived from the projection directories in
644        // `advance_applied_seq`, never incremented here — see the module note
645        // and issue `manifest-counter-desync`. This op also refreshes the run's
646        // last-activity timestamp.
647        m.updated_at = ev.ts;
648        ops.push(ProjectionOp::Manifest(m));
649    }
650    Ok(ops)
651}
652
653/// Rewire an existing node to a freshly re-spawned agent after an empty-handed
654/// `agent-died` bounded auto-retry (issue `autoretry-agent-died-worker`). The
655/// supervisor tore down the dead worker's stale worktree and `create.sh`'d a
656/// clean one at the run's source branch; this event carries the new spawn
657/// metadata (`branch`, `base_sha`, `worktree_path`, tmux identity, `agent_pid`)
658/// plus the audit fields (`attempt`, `reason`).
659///
660/// It updates the node in place: the new agent's coordinates replace the dead
661/// one's, `status` returns to `Pending`, `started_at` is re-stamped so the
662/// watchdog's spawn-grace window re-applies to the new agent, `last_report` is
663/// cleared, and `retry_attempts` is incremented — the DURABLE, restart-safe
664/// bound the watchdog checks before scheduling the next retry.
665///
666/// Guards, mirroring the other node reducers:
667/// - A missing node is a no-op (a retry event whose node was never created).
668/// - A TERMINAL node is never resurrected (a settled node is frozen): if a real
669///   `node.report` raced in and terminalized the node, the retry is a dead event.
670///   This keeps replay robust and preserves the terminal-state invariant.
671fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
672    let events_path = paths.events();
673    let node_id = require_envelope_node_id(&events_path, ev)?;
674    let mut n = match read_node_opt(paths, &node_id)? {
675        Some(n) => n,
676        None => return Ok(vec![]),
677    };
678    // Terminal-state guard: a settled node is frozen. A late `node.report` that
679    // beat this retry to the lock wins; the retry must not resurrect it.
680    if n.status.is_terminal() {
681        tracing::debug!(
682            target: "octl_core::reducer",
683            seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
684            "no-op: node.retry against terminal node"
685        );
686        return Ok(vec![]);
687    }
688    let d = &ev.data;
689    // Rewire to the new agent. Each field mirrors `reduce_node_created`'s parsing
690    // so the projection shape is identical to a fresh spawn.
691    n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
692    n.base_sha = d
693        .get("base_sha")
694        .and_then(Value::as_str)
695        .filter(|s| !s.is_empty())
696        .map(str::to_string);
697    n.worktree_path = d
698        .get("worktree_path")
699        .and_then(Value::as_str)
700        .map(str::to_string);
701    n.tmux_window = d
702        .get("tmux_window")
703        .and_then(Value::as_str)
704        .map(str::to_string);
705    n.tmux_identity = tmux_identity_from_data(d);
706    n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
707    n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
708    n.status = Status::Pending;
709    n.started_at = Some(ev.ts);
710    n.updated_at = ev.ts;
711    n.last_report = None;
712    // Drop any in-flight merge transaction from the PREVIOUS attempt: the retry
713    // rewires the node to a new branch/worktree/agent, so a `pending_merge` that
714    // referenced the dead attempt's branch must not carry forward — recovery would
715    // otherwise judge the new attempt from the old worker's merge state (issue
716    // `merge-transaction-recovery`, /llm-review finding).
717    n.pending_merge = None;
718    // Clear the previous attempt's told exit fact: the freshly re-spawned worker
719    // is a NEW process, so a stale `worker_exit` must not carry over — otherwise
720    // the supervisor's told-fact pass would instantly (mis)judge the new attempt
721    // from the dead one's exit (issue `thin-exit-status-launcher`).
722    n.worker_exit = None;
723    // Clear the previous attempt's first-death anchor: the residual crash backstop
724    // must measure the NEW attempt's own post-death grace from scratch, not inherit
725    // the dead attempt's timestamp (which would fire the backstop with no grace on
726    // the fresh worker's first confirmed death). Issue `typed-supervisor-outcomes`.
727    n.first_death_at = None;
728    // A retry is a new worker attempt. Never carry an unresolved question from
729    // the dead attempt onto the replacement worker.
730    n.awaiting_input = None;
731    // The event carries its ABSOLUTE attempt number (the supervisor set it to
732    // `retry_attempts + 1` at emit time). Assign it directly rather than a blind
733    // `+= 1`: this makes the projection a pure function of the event, so a
734    // full replay from seq 0, or a (guarded-against but defensive) double-apply,
735    // converges to the same `retry_attempts` the log declares — the audit count
736    // and the durable bound can never disagree. A legacy/malformed event with no
737    // parseable `attempt` falls back to the monotone increment.
738    n.retry_attempts = d
739        .get("attempt")
740        .and_then(Value::as_u64)
741        .map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
742    Ok(vec![ProjectionOp::Node(n)])
743}
744
745fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
746    let events_path = paths.events();
747    let node_id = require_envelope_node_id(&events_path, ev)?;
748    let mut n = match read_node_opt(paths, &node_id)? {
749        Some(n) => n,
750        None => return Ok(vec![]),
751    };
752    let new_status = require_status(ev, events_path)?;
753    // Terminal-state guard: a settled node never transitions again. See
754    // run-cli-read/handoff.md D5.
755    if n.status.is_terminal() {
756        trace_terminal_noop(ev, n.status, new_status);
757        return Ok(vec![]);
758    }
759    if n.status == new_status {
760        return Ok(vec![]);
761    }
762    n.status = new_status;
763    // A terminal `node.status` (e.g. a watchdog-synthesized failure) ends the
764    // node's lifecycle, so any in-flight merge transaction is moot — clear it so a
765    // `pending_merge` is not stranded on a terminal node (recovery skips terminal
766    // nodes, so an uncleared one would dangle forever). Issue
767    // `merge-transaction-recovery` (/llm-review finding).
768    if new_status.is_terminal() {
769        n.pending_merge = None;
770        n.awaiting_input = None;
771    }
772    n.updated_at = ev.ts;
773    Ok(vec![ProjectionOp::Node(n)])
774}
775
776fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
777    let events_path = paths.events();
778    let node_id = require_envelope_node_id(&events_path, ev)?;
779    let mut n = match read_node_opt(paths, &node_id)? {
780        Some(n) => n,
781        None => return Ok(vec![]),
782    };
783    // Terminal-state guard *before* payload validation: a node that already
784    // reached a terminal state is settled, so a late-arriving report (e.g. an
785    // agent success racing a `run cancel`) is a dead event — it must not
786    // resurrect the node, and must not even decorate the projection, so
787    // `last_report` is left untouched. Guarding first also keeps replay
788    // robust: a malformed dead report against a settled node is a clean
789    // no-op rather than a `CorruptEventLog` that would brick rebuild of a
790    // log `append_and_apply_event` already committed. See run-cli-read/handoff.md
791    // D5. (3/4 of /llm-review preferred guard-before-validate over the
792    // reverse the issue spec sketched; the required CorruptEventLog cases
793    // all target live nodes, so validation still runs for them.)
794    if n.status.is_terminal() {
795        // ONE exception to the dead-event rule: a late, CONFIRMED explicit-merge
796        // report is adopted even against a terminal node (issue
797        // `reducer-adopt-explicit-merge`). A watchdog `agent-died` false positive
798        // on a long-lived interactive run can terminalize a node BEFORE the user's
799        // `run merge` report arrives; an explicit user merge carries strictly
800        // higher-fidelity ground truth (the branch demonstrably landed in source)
801        // than a watchdog timeout, so it wins. Overwriting `last_report` here is
802        // what lets `any_node_merged_explicitly` see the merge and the SUPERVISOR
803        // — invariant #5's canonical teardown actor — warrant teardown, instead of
804        // the CLI compensating inline (issues `merge-skips-teardown`,
805        // `agent-died-merge-no-teardown-interactive`).
806        //
807        // Scoped tightly, on BOTH sides:
808        //   - incoming: a CONFIRMED SUCCESSFUL explicit merge
809        //     (`via == "explicit-merge"`, `success == true`, not `cancelled`) —
810        //     matches exactly the force-`-D` teardown gate (`node_branch_merged`),
811        //     so a failed/cancelled or non-merge late report never resurrects a
812        //     settled node and unmerged-work preservation is untouched.
813        //   - prior: only a `Failed` or `Done` node (positive whitelist). A
814        //     `Cancelled` terminal is a DELIBERATE `run cancel` teardown, not a
815        //     watchdog false positive, so a later merge does not override it (it
816        //     stays cancelled — matching the existing "late success report keeps the
817        //     cancel" reducer contract). The whitelist (rather than `!= Cancelled`)
818        //     is future-safe: a new deliberate-teardown terminal added later is not
819        //     silently resurrected to Done.
820        // Idempotent: if this exact report is already the node's `last_report`,
821        // re-folding it on replay is a clean no-op (never churns `updated_at`).
822        //
823        // NOTE — the RUN manifest is intentionally NOT reconciled here (it may stay
824        // `Failed` if a supervisor already rolled it up from the watchdog terminal).
825        // That is the pre-existing `false-failed-after-merge` symptom, NOT introduced
826        // by this change (the prior inline reclaim left the manifest `Failed` too):
827        // a run whose manifest was still non-terminal at adoption time DOES roll up
828        // to `Done` (the reattached supervisor's rollup sees the node `Done`); only
829        // an ALREADY-rolled-up terminal manifest stays put, because reconciling a
830        // settled run status is a distinct change to the run-status terminal guard,
831        // deliberately out of scope. Teardown fires either way (gated on
832        // `manifest.status.is_terminal()` + the merge marker), so no resource leaks.
833        if matches!(n.status, Status::Failed | Status::Done)
834            && report_is_confirmed_explicit_merge(&ev.data)
835        {
836            if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
837                return Ok(vec![]);
838            }
839            tracing::info!(
840                target: "octl_core::reducer",
841                seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
842                "adopting late explicit-merge report against terminal node (invariant #5 teardown)"
843            );
844            n.last_report = Some(ev.data.clone());
845            // A confirmed merge is a terminal SUCCESS: the work landed in source.
846            // (A false watchdog `Failed` is corrected to `Done`; a genuine `Done`
847            // stays `Done` with the merge marker adopted so teardown is warranted.)
848            n.status = Status::Done;
849            // The merge completed, so any in-flight merge transaction is resolved:
850            // clear it so recovery does not later re-examine a settled node
851            // (issue `merge-transaction-recovery`).
852            n.pending_merge = None;
853            n.awaiting_input = None;
854            n.updated_at = ev.ts;
855            return Ok(vec![ProjectionOp::Node(n)]);
856        }
857        tracing::debug!(
858            target: "octl_core::reducer",
859            seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
860            "no-op: node.report against terminal node"
861        );
862        return Ok(vec![]);
863    }
864    // Live node: validate the report's terminal outcome. A `node.report`
865    // must express exactly one terminal outcome — success/failure XOR
866    // cancellation. Anything else (a bare `{}` with neither, or the
867    // contradiction `success: true` + `cancelled: true`) is a corrupt event:
868    // the reducer is the canonical gate, so reject it rather than silently
869    // leaving the node in a dangling state. See design.md §7.7 and
870    // node-cli-read/handoff.md D4.
871    let new_status = report_terminal_status(&events_path, ev)?;
872    n.last_report = Some(ev.data.clone());
873    n.status = new_status;
874    // A terminal report settles any open human-decision request. A blocked
875    // report still preserves the discussion in `last_report`, while avoiding a
876    // stale non-terminal awaiting-input flag on the settled node.
877    n.awaiting_input = None;
878    // Any terminal outcome resolves an in-flight merge transaction: a successful
879    // `explicit-merge` report completes it here (the normal, no-crash path), and
880    // any other terminal report ends the node's lifecycle so no merge recovery
881    // should later fire (issue `merge-transaction-recovery`).
882    n.pending_merge = None;
883    n.updated_at = ev.ts;
884    Ok(vec![ProjectionOp::Node(n)])
885}
886
887/// Fold a `worker.exited` event onto the node's `worker_exit` field (design.md
888/// §2.1 / A1). This records the launcher shim's **told** exit status as a
889/// durable fact; it deliberately does NOT transition `status`. Terminalization
890/// is the supervisor's decision via the typed outcome table (§2.6) — a non-zero
891/// or signalled exit becomes `failed`, while a clean exit without a merge stays
892/// non-terminal (attention-required). Keeping the status transition out of the
893/// reducer is what lets the clean-but-unmerged worker remain a visible, resumable
894/// state instead of an auto-failed one.
895///
896/// Payload contract: at least one of `exit_code` (JSON integer) or `signal`
897/// (JSON integer) must be present; a payload carrying neither is a corrupt event
898/// (the reducer is the canonical gate). The fold is idempotent and **first-write-
899/// wins**: once `worker_exit` is set, a replay or a spurious duplicate is a clean
900/// no-op, so a full replay from seq 0 converges to the same recorded fact.
901fn reduce_worker_exited(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
902    let events_path = paths.events();
903    let node_id = require_envelope_node_id(&events_path, ev)?;
904    let code = optional_i32(&ev.data, "exit_code", &events_path, ev)?;
905    let signal = optional_i32(&ev.data, "signal", &events_path, ev)?;
906    // A worker exit is EXACTLY one of a normal return (code) or a signal death
907    // (signal). Neither is meaningless; both is contradictory (a process cannot
908    // both return a code and be killed) — reject either rather than record an
909    // ambiguous fact the outcome classifier would then have to disambiguate.
910    match (code, signal) {
911        (Some(_), None) | (None, Some(_)) => {}
912        _ => {
913            return Err(Error::CorruptEventLog {
914                path: events_path,
915                reason: format!(
916                    "event seq={} kind=worker.exited must carry EXACTLY one of `exit_code` or `signal`",
917                    ev.seq
918                ),
919            });
920        }
921    }
922    let mut n = match read_node_opt(paths, &node_id)? {
923        Some(n) => n,
924        // No projection to decorate. A `worker.exited` for a node that does not
925        // exist folds to nothing — consistent with the other node reducers
926        // (`node.report` / `node.status`). In practice the shim validates the node
927        // exists before it can record an exit, and normal append ordering always
928        // places `node.created` first, so this is only hit for a genuinely orphan
929        // event.
930        None => return Ok(vec![]),
931    };
932    // First-write-wins: the shim fires exactly once per worker, so an existing
933    // record is a replay/duplicate. Leaving it untouched keeps the fold a pure
934    // function of the first exit event and never churns `updated_at`.
935    if n.worker_exit.is_some() {
936        return Ok(vec![]);
937    }
938    n.worker_exit = Some(WorkerExit {
939        code,
940        signal,
941        at: ev.ts,
942    });
943    // A departed worker cannot proceed on its recommended default. Clear its
944    // open request so clean-exit attention and crash/stall handling remain the
945    // actionable read-surface verdicts.
946    n.awaiting_input = None;
947    n.updated_at = ev.ts;
948    Ok(vec![ProjectionOp::Node(n)])
949}
950
951/// Fold a `node.death_observed` event onto [`Node::first_death_at`], recording
952/// the FIRST tick on which the supervisor saw this node's worker confirmed-dead
953/// with no told `worker.exited` and no merge — the durable anchor for the
954/// residual crash backstop's fixed post-death grace (design.md §2.1a, issue
955/// `typed-supervisor-outcomes`).
956///
957/// The anchor is the event's own timestamp (`ev.ts`) — no payload field needed.
958/// The fold is **first-write-wins**: the anchor is monotonic, so a later
959/// re-observation (a supervisor restart still seeing the dead pid) never resets
960/// the clock, and a full replay from seq 0 converges to the first observation. A
961/// `node.death_observed` for a missing or already terminal node folds to nothing
962/// (the backstop is moot once the node settles).
963fn reduce_node_death_observed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
964    let events_path = paths.events();
965    let node_id = require_envelope_node_id(&events_path, ev)?;
966    let mut n = match read_node_opt(paths, &node_id)? {
967        Some(n) => n,
968        None => return Ok(vec![]),
969    };
970    // First-write-wins (monotonic anchor). No-op once the backstop is moot or a
971    // higher-fidelity fact exists — a terminal node, a told `worker.exited`, a
972    // landed report, or an in-flight merge transaction. The supervisor's emitter
973    // already gates on all of these under the exclusive lock; mirroring them here
974    // keeps a from-scratch replay convergent regardless of caller.
975    if n.first_death_at.is_some()
976        || n.status.is_terminal()
977        || n.worker_exit.is_some()
978        || n.last_report.is_some()
979        || n.pending_merge.is_some()
980    {
981        return Ok(vec![]);
982    }
983    n.first_death_at = Some(ev.ts);
984    n.updated_at = ev.ts;
985    Ok(vec![ProjectionOp::Node(n)])
986}
987
988/// Fold an agent's explicit request for a human decision onto the node without
989/// changing its status. This is deliberately non-terminal: the worker may still
990/// resolve the fork itself or proceed with its stated default.
991///
992/// The first open signal wins until a matching `node.input_resolved` clears it,
993/// so retries or duplicate writes cannot move the grace clock forward. The
994/// event timestamp, not a caller-supplied payload timestamp, is the durable
995/// restart-safe anchor.
996fn reduce_node_awaiting_input(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
997    const MAX_ITEMS: usize = 8;
998    const MAX_TOPIC_CHARS: usize = 512;
999    const MAX_OPTIONS: usize = 16;
1000    const MAX_OPTION_CHARS: usize = 256;
1001
1002    let events_path = paths.events();
1003    let node_id = require_envelope_node_id(&events_path, ev)?;
1004    // Validate before every state-dependent no-op. An ignored duplicate or late
1005    // event must still be structurally valid before it enters the durable log.
1006    let items = ev
1007        .data
1008        .get("discussion_items")
1009        .and_then(Value::as_array)
1010        .filter(|items| !items.is_empty() && items.len() <= MAX_ITEMS)
1011        .ok_or_else(|| Error::CorruptEventLog {
1012            path: events_path.clone(),
1013            reason: format!(
1014                "event seq={} kind=node.awaiting_input requires 1..={MAX_ITEMS} `discussion_items`",
1015                ev.seq
1016            ),
1017        })?;
1018    for (index, item) in items.iter().enumerate() {
1019        let obj = item.as_object().ok_or_else(|| Error::CorruptEventLog {
1020            path: events_path.clone(),
1021            reason: format!(
1022                "event seq={} discussion_items[{index}] must be an object",
1023                ev.seq
1024            ),
1025        })?;
1026        let topic = obj.get("topic").and_then(Value::as_str).unwrap_or("");
1027        let default = obj
1028            .get("recommended_default")
1029            .and_then(Value::as_str)
1030            .unwrap_or("");
1031        let options = obj.get("options").and_then(Value::as_array);
1032        let options_valid = options.is_some_and(|values| {
1033            !values.is_empty()
1034                && values.len() <= MAX_OPTIONS
1035                && values.iter().all(|v| {
1036                    v.as_str().is_some_and(|s| {
1037                        !s.trim().is_empty() && s.chars().count() <= MAX_OPTION_CHARS
1038                    })
1039                })
1040                && values.iter().any(|v| v.as_str() == Some(default))
1041        });
1042        if topic.trim().is_empty()
1043            || topic.chars().count() > MAX_TOPIC_CHARS
1044            || default.trim().is_empty()
1045            || !options_valid
1046        {
1047            return Err(Error::CorruptEventLog {
1048                path: events_path.clone(),
1049                reason: format!(
1050                    "event seq={} discussion_items[{index}] requires bounded non-empty `topic`, 1..={MAX_OPTIONS} bounded string `options`, and a `recommended_default` present in options",
1051                    ev.seq
1052                ),
1053            });
1054        }
1055    }
1056
1057    let mut n = match read_node_opt(paths, &node_id)? {
1058        Some(n) => n,
1059        None => return Ok(vec![]),
1060    };
1061    if n.status.is_terminal() || n.worker_exit.is_some() {
1062        return Ok(vec![]);
1063    }
1064    if let Some(open) = n.awaiting_input.as_mut() {
1065        // A later fork joins the current open generation without moving its
1066        // restart-safe clock or notification key. Bound the aggregate too.
1067        if open.discussion_items.len() + items.len() > MAX_ITEMS {
1068            return Err(Error::CorruptEventLog {
1069                path: events_path,
1070                reason: format!(
1071                    "event seq={} would exceed {MAX_ITEMS} open discussion items",
1072                    ev.seq
1073                ),
1074            });
1075        }
1076        open.discussion_items.extend(items.iter().cloned());
1077    } else {
1078        n.awaiting_input = Some(Box::new(crate::schema::AwaitingInput {
1079            opened_at: ev.ts,
1080            event_seq: ev.seq,
1081            discussion_items: items.clone(),
1082        }));
1083    }
1084    n.updated_at = ev.ts;
1085    Ok(vec![ProjectionOp::Node(n)])
1086}
1087
1088/// Clear the current open decision request. `event_seq` is mandatory and
1089/// fences the resolve to the generation the worker observed, so a delayed
1090/// timeout cannot clear a newer question opened after the old one resolved.
1091fn reduce_node_input_resolved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1092    let events_path = paths.events();
1093    let node_id = require_envelope_node_id(&events_path, ev)?;
1094    // Validate before the no-open no-op so malformed events never enter the log
1095    // merely because their projection happens to be absent today.
1096    let seq = ev
1097        .data
1098        .get("event_seq")
1099        .and_then(Value::as_u64)
1100        .ok_or_else(|| Error::CorruptEventLog {
1101            path: events_path.clone(),
1102            reason: format!(
1103                "event seq={} kind=node.input_resolved requires unsigned `event_seq`",
1104                ev.seq
1105            ),
1106        })?;
1107    let mut n = match read_node_opt(paths, &node_id)? {
1108        Some(n) => n,
1109        None => return Ok(vec![]),
1110    };
1111    let Some(open) = n.awaiting_input.as_ref() else {
1112        return Ok(vec![]);
1113    };
1114    if seq != open.event_seq {
1115        return Ok(vec![]);
1116    }
1117    n.awaiting_input = None;
1118    n.updated_at = ev.ts;
1119    Ok(vec![ProjectionOp::Node(n)])
1120}
1121
1122/// The event kind `run merge` appends BEFORE mutating git to record the
1123/// in-flight merge transaction (design.md §2.1b / A2). Its `data` payload is a
1124/// serialized [`MergeTxn`]; the reducer folds it onto [`Node::pending_merge`].
1125pub const KIND_MERGE_STARTED: &str = "merge.started";
1126
1127/// The event kind recovery appends when it resolves a pending merge transaction
1128/// by REJECTING it — the recorded source ref never moved (the git mutation never
1129/// landed), so the worker's branch + work are preserved and the transaction is
1130/// cleared. Its `data` carries `op_id` (which transaction) and `reason`.
1131pub const KIND_MERGE_ABORTED: &str = "merge.aborted";
1132
1133/// Fold a `merge.started` event onto [`Node::pending_merge`], recording the
1134/// in-flight `run merge` transaction BEFORE the git mutation so a crash between
1135/// the git merge and the terminal `explicit-merge` report can be resolved
1136/// deterministically by OID (design.md §2.1b / A2, issue
1137/// `merge-transaction-recovery`). The reducer deliberately does NOT transition
1138/// `status`: recording a transaction is not a terminal outcome.
1139///
1140/// Payload contract: the `data` is a serialized [`MergeTxn`]; a payload missing
1141/// required fields is a corrupt event (the reducer is the canonical gate, so the
1142/// append is rejected before any byte is written). The fold is idempotent —
1143/// re-folding the same `op_id` on replay is a clean no-op — and last-write-wins
1144/// across a fresh attempt's larger `op_id` (each `run merge` re-reads
1145/// `expected_source_oid`, so the newest record is authoritative).
1146fn reduce_merge_started(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1147    let events_path = paths.events();
1148    let node_id = require_envelope_node_id(&events_path, ev)?;
1149    let txn: MergeTxn =
1150        serde_json::from_value(ev.data.clone()).map_err(|e| Error::CorruptEventLog {
1151            path: events_path.clone(),
1152            reason: format!(
1153                "event seq={} kind=merge.started has an invalid MergeTxn payload: {e}",
1154                ev.seq
1155            ),
1156        })?;
1157    let mut n = match read_node_opt(paths, &node_id)? {
1158        Some(n) => n,
1159        None => return Ok(vec![]),
1160    };
1161    // A terminal node has no in-flight merge to track — `run merge` is refused on
1162    // a terminal run at the CLI, so this is a dead/duplicate event. Ignore it
1163    // (never resurrect the projection).
1164    if n.status.is_terminal() {
1165        return Ok(vec![]);
1166    }
1167    // Idempotent: re-folding the SAME transaction on replay must not churn
1168    // `updated_at`.
1169    if n.pending_merge.as_ref().map(|t| t.op_id.as_str()) == Some(txn.op_id.as_str()) {
1170        return Ok(vec![]);
1171    }
1172    n.pending_merge = Some(Box::new(txn));
1173    n.updated_at = ev.ts;
1174    Ok(vec![ProjectionOp::Node(n)])
1175}
1176
1177/// Fold a `merge.aborted` event, clearing [`Node::pending_merge`] iff it names
1178/// the transaction being aborted (`op_id` match). Recovery appends this when it
1179/// determines a pending merge's git mutation never landed (the source ref is
1180/// still at `expected_source_oid`) or moved unexpectedly — the transaction is
1181/// rejected, the worker's branch + work are preserved, and the node stays
1182/// whatever non-terminal status it was (a retry may re-attempt the merge).
1183///
1184/// The `op_id` guard is what keeps this from clobbering a *newer* transaction: a
1185/// stale `merge.aborted` for a superseded attempt (a different `op_id`) is a
1186/// clean no-op. Deliberately
1187/// does NOT transition `status`.
1188fn reduce_merge_aborted(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1189    let events_path = paths.events();
1190    let node_id = require_envelope_node_id(&events_path, ev)?;
1191    let op_id = ev
1192        .data
1193        .get("op_id")
1194        .and_then(Value::as_str)
1195        .ok_or_else(|| Error::CorruptEventLog {
1196            path: events_path.clone(),
1197            reason: format!(
1198                "event seq={} kind=merge.aborted is missing string `op_id`",
1199                ev.seq
1200            ),
1201        })?;
1202    let mut n = match read_node_opt(paths, &node_id)? {
1203        Some(n) => n,
1204        None => return Ok(vec![]),
1205    };
1206    // Clear only the transaction this event names. A mismatch (already resolved,
1207    // or a newer attempt is pending) is a clean no-op.
1208    match n.pending_merge.as_ref() {
1209        Some(t) if t.op_id == op_id => {}
1210        _ => return Ok(vec![]),
1211    }
1212    n.pending_merge = None;
1213    n.updated_at = ev.ts;
1214    Ok(vec![ProjectionOp::Node(n)])
1215}
1216
1217/// Emit an observability trace for a status event dropped by the terminal
1218/// guard. Re-applying the *same* terminal status is routine idempotent replay
1219/// (`debug`); an event carrying a *different* status is a real conflict that
1220/// should not occur on a well-formed log (`warn`) — e.g. a `done` node being
1221/// told to go `cancelled`. The guard no-ops either way; the level is the only
1222/// difference, so a genuine corruption signal is visible without flooding
1223/// logs on every replay.
1224fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
1225    if current == incoming {
1226        tracing::debug!(
1227            target: "octl_core::reducer",
1228            seq = ev.seq, kind = %ev.kind, status = ?current,
1229            "no-op: status re-applied to terminal target"
1230        );
1231    } else {
1232        tracing::warn!(
1233            target: "octl_core::reducer",
1234            seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
1235            "no-op: ignored conflicting transition from terminal target"
1236        );
1237    }
1238}
1239
1240/// True when a `node.report` payload is a CONFIRMED, SUCCESSFUL explicit merge —
1241/// the sole payload shape the terminal-node guard in [`reduce_node_report`]
1242/// adopts. Delegates to [`ReportOrigin::report_is_confirmed_merge`] so the
1243/// reducer's adoption gate reads the SAME merge truth as the supervisor's
1244/// teardown gate, the `landed` fallback, and `run wait`'s `merged` flag.
1245///
1246/// That truth prefers the typed [`ReportOrigin::RunMerge`] (issue
1247/// `retire-via-string`): the legacy `via: "explicit-merge"` string is honored
1248/// only as a fallback for a legacy report carrying NO `origin` field, so an
1249/// agent-authored report (normalized to an [`ReportOrigin::Agent`] origin by
1250/// `node report`) can never be adopted against a settled node on a forged `via`
1251/// string alone. It still requires `success == true` with `cancelled`
1252/// absent/`false` and strict boolean typing (a malformed payload a live node
1253/// would reject as `CorruptEventLog` cannot sneak an adoption in through this
1254/// terminal-only exception), and returns `false` rather than erroring so a
1255/// replay of such a dead event stays a clean no-op.
1256fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
1257    ReportOrigin::report_is_confirmed_merge(data)
1258}
1259
1260/// Derive the terminal status a `node.report` event asserts, enforcing the
1261/// success-XOR-cancelled invariant with strict boolean typing.
1262///
1263/// `cancelled: true` (with `success: false` or absent) → [`Status::Cancelled`].
1264/// Otherwise `success` must be present: `true` → [`Status::Done`], `false` →
1265/// [`Status::Failed`]. Neither field (bare `{}`), the contradiction
1266/// `success: true` + `cancelled: true`, or a non-boolean `success` /
1267/// `cancelled` is a [`Error::CorruptEventLog`].
1268fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
1269    let corrupt = |reason: String| Error::CorruptEventLog {
1270        path: events_path.to_path_buf(),
1271        reason,
1272    };
1273    let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
1274    let success = optional_bool(events_path, ev, &ev.data, "success")?;
1275    if cancelled {
1276        if success == Some(true) {
1277            return Err(corrupt(format!(
1278                "event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
1279                ev.seq
1280            )));
1281        }
1282        Ok(Status::Cancelled)
1283    } else {
1284        match success {
1285            Some(true) => Ok(Status::Done),
1286            Some(false) => Ok(Status::Failed),
1287            None => Err(corrupt(format!(
1288                "event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
1289                ev.seq
1290            ))),
1291        }
1292    }
1293}
1294
1295fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1296    // `child.spawned` is written to the PARENT run's events; the parent
1297    // spawning node is `ev.node_id`, the child run/node lives in `data`.
1298    let events_path = paths.events();
1299    let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
1300        path: events_path.clone(),
1301        reason: format!(
1302            "event seq={} kind=child.spawned missing parent `node_id`",
1303            ev.seq
1304        ),
1305    })?;
1306    let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
1307        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1308    let child_node_id = NodeId::parse_str(
1309        ev.data
1310            .get("child_node_id")
1311            .and_then(Value::as_str)
1312            .unwrap_or("n-0001"),
1313    )
1314    .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1315    let mut n = match read_node_opt(paths, &parent_node_id)? {
1316        Some(n) => n,
1317        None => return Ok(vec![]),
1318    };
1319    let new_ref = ChildRef {
1320        run_id: child_run_id,
1321        node_id: child_node_id,
1322    };
1323    if n.children.iter().any(|c| c == &new_ref) {
1324        // Already recorded — pure no-op so replayed events don't churn
1325        // `updated_at` or the projection file.
1326        return Ok(vec![]);
1327    }
1328    n.children.push(new_ref);
1329    n.updated_at = ev.ts;
1330    Ok(vec![ProjectionOp::Node(n)])
1331}
1332
1333/// `supervisor.attached` records the supervisor PID watching the envelope
1334/// node onto `Node.supervisor_pid`. Event-sourced replacement for the
1335/// supervisor's former direct `write_node` (issue
1336/// `supervisor-state-not-event-sourced`), so a from-scratch projection
1337/// rebuild reproduces the field.
1338///
1339/// Latest-wins: a later attach (a supervisor restart binds a fresh PID)
1340/// overrides the recorded value. Re-applying an event that carries the
1341/// already-recorded PID is a pure no-op, so replay never churns the
1342/// projection file's `updated_at`.
1343fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1344    let events_path = paths.events();
1345    let node_id = require_envelope_node_id(&events_path, ev)?;
1346    let raw = ev
1347        .data
1348        .get("pid")
1349        .and_then(Value::as_i64)
1350        .ok_or_else(|| Error::CorruptEventLog {
1351            path: events_path.clone(),
1352            reason: format!(
1353                "event seq={} kind=supervisor.attached missing/invalid `pid`",
1354                ev.seq
1355            ),
1356        })?;
1357    let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
1358        path: events_path.clone(),
1359        reason: format!(
1360            "event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
1361            ev.seq
1362        ),
1363    })?;
1364    let mut n = match read_node_opt(paths, &node_id)? {
1365        Some(n) => n,
1366        None => return Ok(vec![]),
1367    };
1368    if n.supervisor_pid == Some(pid) {
1369        return Ok(vec![]);
1370    }
1371    n.supervisor_pid = Some(pid);
1372    n.updated_at = ev.ts;
1373    Ok(vec![ProjectionOp::Node(n)])
1374}
1375
1376/// `supervisor.cursor_advanced` mirrors the supervisor's per-child report
1377/// cursor onto the envelope (parent) node's `last_processed_report_seq_by_child`
1378/// map. Event-sourced replacement for the supervisor's former direct
1379/// `write_node` of that map (issue `supervisor-state-not-event-sourced`).
1380///
1381/// The cursor is monotonic: a `report_seq` at or below the recorded
1382/// high-water mark for this child is a no-op, so replaying the same event —
1383/// or an older out-of-order one — never moves the cursor backward or churns
1384/// the projection. This is the §7.3 idempotency guarantee at the reducer
1385/// boundary.
1386fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1387    let events_path = paths.events();
1388    let node_id = require_envelope_node_id(&events_path, ev)?;
1389    let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
1390    // Validate the child id even though it only becomes a map key — a forged
1391    // event must not smuggle a path-shaped or malformed run id into the
1392    // projection.
1393    RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
1394    let report_seq = ev
1395        .data
1396        .get("report_seq")
1397        .and_then(Value::as_u64)
1398        .ok_or_else(|| Error::CorruptEventLog {
1399            path: events_path.clone(),
1400            reason: format!(
1401                "event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
1402                ev.seq
1403            ),
1404        })?;
1405    let mut n = match read_node_opt(paths, &node_id)? {
1406        Some(n) => n,
1407        None => return Ok(vec![]),
1408    };
1409    if let Some(prev) = n
1410        .last_processed_report_seq_by_child
1411        .get(child_run_id)
1412        .and_then(Value::as_u64)
1413    {
1414        if report_seq <= prev {
1415            return Ok(vec![]);
1416        }
1417    }
1418    n.last_processed_report_seq_by_child
1419        .insert(child_run_id.to_string(), Value::from(report_seq));
1420    n.updated_at = ev.ts;
1421    Ok(vec![ProjectionOp::Node(n)])
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427    use crate::schema::Event;
1428    use chrono::Utc;
1429    use tempfile::TempDir;
1430
1431    fn event(run_id: &str) -> Event {
1432        Event {
1433            ts: Utc::now(),
1434            seq: 1,
1435            kind: "run.status".into(),
1436            run_id: RunId::parse_str(run_id).unwrap(),
1437            node_id: None,
1438            idempotency_key: None,
1439            data: serde_json::json!({ "status": "running" }),
1440        }
1441    }
1442
1443    #[test]
1444    fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
1445        // The /orchestrate audit kinds are append-only: the reducer must plan
1446        // ZERO projection ops for them regardless of payload, so the event log
1447        // is their sole home and no projection is created or mutated.
1448        let tmp = TempDir::new().unwrap();
1449        let run_id = "01jxsnap000000000000000000";
1450        let rid = RunId::parse_str(run_id).unwrap();
1451        let dir = crate::run_dir(tmp.path(), &rid);
1452        std::fs::create_dir_all(&dir).unwrap();
1453        let paths = RunPaths::new(dir, run_id).unwrap();
1454
1455        // Bootstrap a manifest so we can prove the audit events leave it
1456        // byte-for-byte untouched (no counter churn, no status drift).
1457        let mut created = event(run_id);
1458        created.kind = "run.created".into();
1459        created.data = serde_json::json!({
1460            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1461        });
1462        apply_event(&paths, &created).expect("run.created applies");
1463        let manifest_before = std::fs::read(paths.manifest()).unwrap();
1464
1465        for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
1466            let mut ev = event(run_id);
1467            ev.seq = seq;
1468            ev.kind = kind.into();
1469            // A non-trivial payload to prove the reducer ignores it wholesale.
1470            ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
1471            let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
1472            assert!(ops.is_empty(), "{kind} must plan no projection ops");
1473            // apply_event is the plan+commit path; it must also be a clean no-op.
1474            apply_event(&paths, &ev).expect("audit kind applies as no-op");
1475        }
1476
1477        // The manifest is unchanged and no stray projection dirs appeared.
1478        assert_eq!(
1479            std::fs::read(paths.manifest()).unwrap(),
1480            manifest_before,
1481            "audit events must not mutate the manifest"
1482        );
1483        assert!(!paths.nodes_dir().exists(), "no node projection created");
1484    }
1485
1486    #[test]
1487    fn run_created_folds_harness_when_present_and_defaults_none() {
1488        let tmp = TempDir::new().unwrap();
1489
1490        // A `run.created` carrying `harness` folds it onto the manifest.
1491        let run_id = "01jxhrnsaa0000000000000001";
1492        let rid = RunId::parse_str(run_id).unwrap();
1493        let dir = crate::run_dir(tmp.path(), &rid);
1494        std::fs::create_dir_all(&dir).unwrap();
1495        let paths = RunPaths::new(dir, run_id).unwrap();
1496        let mut created = event(run_id);
1497        created.kind = "run.created".into();
1498        created.data = serde_json::json!({
1499            "kind": "spinoff", "lifecycle": "autonomous", "title": "t",
1500            "harness": "pi", "harness_source": "flag",
1501        });
1502        apply_event(&paths, &created).expect("run.created applies");
1503        let m = read_manifest_opt(&paths).unwrap().unwrap();
1504        assert_eq!(m.harness.as_deref(), Some("pi"));
1505
1506        // A `run.created` WITHOUT `harness` (legacy / claude) leaves it `None`.
1507        let run_id2 = "01jxhrnsaa0000000000000002";
1508        let rid2 = RunId::parse_str(run_id2).unwrap();
1509        let dir2 = crate::run_dir(tmp.path(), &rid2);
1510        std::fs::create_dir_all(&dir2).unwrap();
1511        let paths2 = RunPaths::new(dir2, run_id2).unwrap();
1512        let mut created2 = event(run_id2);
1513        created2.kind = "run.created".into();
1514        created2.data =
1515            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1516        apply_event(&paths2, &created2).expect("run.created applies");
1517        let m2 = read_manifest_opt(&paths2).unwrap().unwrap();
1518        assert_eq!(m2.harness, None);
1519    }
1520
1521    /// Bootstrap a run manifest + one live `n-0001` spinoff node, returning its
1522    /// paths. Used by the `node.retry` reducer tests.
1523    fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1524        let rid = RunId::parse_str(run_id).unwrap();
1525        let dir = crate::run_dir(tmp.path(), &rid);
1526        std::fs::create_dir_all(&dir).unwrap();
1527        let paths = RunPaths::new(dir, run_id).unwrap();
1528        let mut created = event(run_id);
1529        created.kind = "run.created".into();
1530        created.data =
1531            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1532        apply_event(&paths, &created).expect("run.created applies");
1533        let mut node = event(run_id);
1534        node.seq = 2;
1535        node.kind = "node.created".into();
1536        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1537        node.data = serde_json::json!({
1538            "kind": "spinoff",
1539            "branch": "wt/foo",
1540            "worktree_path": "/tmp/old-wt",
1541            "agent_pid": 111,
1542        });
1543        apply_event(&paths, &node).expect("node.created applies");
1544        paths
1545    }
1546
1547    /// `node.retry` rewires the node to the freshly re-spawned agent, returns it to
1548    /// `Pending`, re-stamps `started_at`, and increments the durable
1549    /// `retry_attempts` bound (issue `autoretry-agent-died-worker`).
1550    #[test]
1551    fn node_retry_rewires_node_and_increments_attempts() {
1552        let tmp = TempDir::new().unwrap();
1553        let run_id = "01jxsnap000000000000000000";
1554        let paths = bootstrap_retry_node(&tmp, run_id);
1555
1556        let mut retry = event(run_id);
1557        retry.seq = 3;
1558        retry.kind = "node.retry".into();
1559        retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1560        retry.data = serde_json::json!({
1561            "attempt": 1,
1562            "reason": "agent-died",
1563            "branch": "wt/foo-r1",
1564            "base_sha": "a".repeat(40),
1565            "worktree_path": "/tmp/new-wt",
1566            "agent_pid": 222,
1567            "tmux_session": "s",
1568            "tmux_window_id": "@9",
1569        });
1570        apply_event(&paths, &retry).expect("node.retry applies");
1571
1572        let n = read_n0001(&paths);
1573        assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
1574        assert_eq!(
1575            n.branch.as_deref(),
1576            Some("wt/foo-r1"),
1577            "rewired to new branch"
1578        );
1579        assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
1580        assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
1581        assert_eq!(n.status, Status::Pending, "node returns to pending");
1582        assert!(n.last_report.is_none());
1583        assert_eq!(
1584            n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
1585            Some("@9"),
1586            "rewired tmux identity"
1587        );
1588
1589        // A second retry increments again — the bound is monotone.
1590        let mut retry2 = event(run_id);
1591        retry2.seq = 4;
1592        retry2.kind = "node.retry".into();
1593        retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1594        retry2.data = serde_json::json!({
1595            "attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
1596            "worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
1597        });
1598        apply_event(&paths, &retry2).expect("node.retry applies");
1599        assert_eq!(read_n0001(&paths).retry_attempts, 2);
1600    }
1601
1602    /// A `node.retry` against an already-terminal node is a dead event: the
1603    /// terminal-state invariant holds, so a late retry never resurrects a settled
1604    /// node (a real report that raced in wins).
1605    #[test]
1606    fn node_retry_against_terminal_node_is_noop() {
1607        let tmp = TempDir::new().unwrap();
1608        let run_id = "01jxsnap000000000000000000";
1609        let paths = bootstrap_retry_node(&tmp, run_id);
1610
1611        // Terminalize the node via a success report.
1612        let mut report = event(run_id);
1613        report.seq = 3;
1614        report.kind = "node.report".into();
1615        report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1616        report.data = serde_json::json!({ "success": true });
1617        apply_event(&paths, &report).expect("node.report applies");
1618        assert_eq!(read_n0001(&paths).status, Status::Done);
1619
1620        let mut retry = event(run_id);
1621        retry.seq = 4;
1622        retry.kind = "node.retry".into();
1623        retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1624        retry.data = serde_json::json!({
1625            "attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
1626            "worktree_path": "/tmp/new-wt", "agent_pid": 222,
1627        });
1628        apply_event(&paths, &retry).expect("node.retry applies as no-op");
1629
1630        let n = read_n0001(&paths);
1631        assert_eq!(n.status, Status::Done, "terminal node not resurrected");
1632        assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
1633        assert_eq!(n.agent_pid, Some(111), "not rewired");
1634    }
1635
1636    #[test]
1637    fn apply_event_rejects_event_from_a_different_run() {
1638        let tmp = TempDir::new().unwrap();
1639        let run_id = "01jxsnap000000000000000000";
1640        let rid = RunId::parse_str(run_id).unwrap();
1641        let dir = crate::run_dir(tmp.path(), &rid);
1642        std::fs::create_dir_all(&dir).unwrap();
1643        let paths = RunPaths::new(dir, run_id).unwrap();
1644
1645        // An event whose envelope names a different run must not be folded.
1646        let foreign = event("02jxsnap000000000000000000");
1647        let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
1648        assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
1649
1650        // The matching run_id is accepted (no projection exists yet, so
1651        // `run.status` is a clean no-op rather than an error).
1652        let mine = event(run_id);
1653        apply_event(&paths, &mine).expect("matching run_id must be accepted");
1654    }
1655
1656    #[test]
1657    fn tmux_identity_from_data_reads_qualified_fields() {
1658        let d = serde_json::json!({
1659            "tmux_socket": "/private/tmp/tmux-501/default",
1660            "tmux_session": "octl",
1661            "tmux_window_id": "@42",
1662        });
1663        let id = tmux_identity_from_data(&d).expect("qualified identity");
1664        assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
1665        assert_eq!(id.session, "octl");
1666        assert_eq!(id.window_id, "@42");
1667        // No pane_id in this event → None (back-compat / older create.sh).
1668        assert_eq!(id.pane_id, None);
1669
1670        // Null socket is tolerated — session + window_id are the minimum.
1671        let d2 = serde_json::json!({
1672            "tmux_socket": null,
1673            "tmux_session": "octl",
1674            "tmux_window_id": "@7",
1675        });
1676        let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
1677        assert_eq!(id2.socket, None);
1678        assert_eq!(id2.window_id, "@7");
1679
1680        // A create.sh that emits `tmux_pane_id` is folded into the identity.
1681        let d3 = serde_json::json!({
1682            "tmux_session": "octl",
1683            "tmux_window_id": "@42",
1684            "tmux_pane_id": "%7",
1685        });
1686        let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
1687        assert_eq!(id3.pane_id.as_deref(), Some("%7"));
1688        assert_eq!(id3.capture_target(), "%7");
1689
1690        // Explicit `tmux_pane_id: null` (create.sh emits null when its pane
1691        // query failed) must fold to None — never `Some("null")`.
1692        let d4 = serde_json::json!({
1693            "tmux_session": "octl",
1694            "tmux_window_id": "@42",
1695            "tmux_pane_id": null,
1696        });
1697        let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
1698        assert_eq!(id4.pane_id, None);
1699        assert_eq!(id4.capture_target(), "@42");
1700    }
1701
1702    #[test]
1703    fn tmux_identity_from_data_back_compat_is_none() {
1704        // Legacy create.sh: no qualified fields at all.
1705        let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
1706        assert!(tmux_identity_from_data(&legacy).is_none());
1707        // Partial (window_id without session) is also insufficient → None.
1708        let partial = serde_json::json!({ "tmux_window_id": "@42" });
1709        assert!(tmux_identity_from_data(&partial).is_none());
1710    }
1711
1712    /// End-to-end: a `node.created` event carrying the qualified fields folds
1713    /// them into `Node.tmux_identity`; one without them leaves it `None`.
1714    #[test]
1715    fn node_created_populates_tmux_identity() {
1716        let tmp = TempDir::new().unwrap();
1717        let run_id = "01jxsnap000000000000000000";
1718        let rid = RunId::parse_str(run_id).unwrap();
1719        let dir = crate::run_dir(tmp.path(), &rid);
1720        std::fs::create_dir_all(&dir).unwrap();
1721        let paths = RunPaths::new(dir, run_id).unwrap();
1722
1723        let mut ev = event(run_id);
1724        ev.seq = 2;
1725        ev.kind = "node.created".into();
1726        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1727        ev.data = serde_json::json!({
1728            "kind": "spinoff",
1729            "tmux_window": "🚀 wt/x",
1730            "tmux_socket": "/private/tmp/tmux-501/default",
1731            "tmux_session": "octl",
1732            "tmux_window_id": "@42",
1733        });
1734        apply_event(&paths, &ev).expect("node.created applies");
1735        let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
1736            .unwrap()
1737            .unwrap();
1738        let id = n.tmux_identity.expect("qualified identity recorded");
1739        assert_eq!(id.session, "octl");
1740        assert_eq!(id.window_id, "@42");
1741        assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
1742
1743        // A second run with a legacy event leaves tmux_identity None.
1744        let run2 = "02jxsnap000000000000000000";
1745        let rid2 = RunId::parse_str(run2).unwrap();
1746        let dir2 = crate::run_dir(tmp.path(), &rid2);
1747        std::fs::create_dir_all(&dir2).unwrap();
1748        let paths2 = RunPaths::new(dir2, run2).unwrap();
1749        let mut ev2 = event(run2);
1750        ev2.seq = 2;
1751        ev2.kind = "node.created".into();
1752        ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1753        ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
1754        apply_event(&paths2, &ev2).expect("legacy node.created applies");
1755        let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
1756            .unwrap()
1757            .unwrap();
1758        assert!(n2.tmux_identity.is_none());
1759        assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
1760    }
1761
1762    #[test]
1763    fn node_materialization_populates_missing_manifest_source_branch() {
1764        let tmp = TempDir::new().unwrap();
1765        let run_id = "01jxsnap000000000000000001";
1766        let rid = RunId::parse_str(run_id).unwrap();
1767        let dir = crate::run_dir(tmp.path(), &rid);
1768        std::fs::create_dir_all(&dir).unwrap();
1769        let paths = RunPaths::new(dir, run_id).unwrap();
1770
1771        let mut created = event(run_id);
1772        created.kind = "run.created".into();
1773        created.data = serde_json::json!({
1774            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1775        });
1776        apply_event(&paths, &created).unwrap();
1777        assert!(read_manifest_opt(&paths)
1778            .unwrap()
1779            .unwrap()
1780            .source_branch
1781            .is_none());
1782
1783        let mut node = event(run_id);
1784        node.seq = 2;
1785        node.kind = "node.created".into();
1786        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1787        node.data = serde_json::json!({
1788            "kind": "spinoff",
1789            "source_branch": "main",
1790            "worktree_path": "/tmp/wt/pending"
1791        });
1792        apply_event(&paths, &node).unwrap();
1793
1794        let manifest = read_manifest_opt(&paths).unwrap().unwrap();
1795        assert_eq!(manifest.status, Status::Pending);
1796        assert_eq!(manifest.source_branch.as_deref(), Some("main"));
1797        let node = read_n0001(&paths);
1798        assert_eq!(node.worktree_path.as_deref(), Some("/tmp/wt/pending"));
1799    }
1800
1801    #[test]
1802    fn node_materialization_preserves_explicit_manifest_source_branch() {
1803        let tmp = TempDir::new().unwrap();
1804        let run_id = "01jxsnap000000000000000002";
1805        let rid = RunId::parse_str(run_id).unwrap();
1806        let dir = crate::run_dir(tmp.path(), &rid);
1807        std::fs::create_dir_all(&dir).unwrap();
1808        let paths = RunPaths::new(dir, run_id).unwrap();
1809
1810        let mut created = event(run_id);
1811        created.kind = "run.created".into();
1812        created.data = serde_json::json!({
1813            "kind": "spinoff", "lifecycle": "autonomous", "title": "t",
1814            "source_branch": "release"
1815        });
1816        apply_event(&paths, &created).unwrap();
1817
1818        let mut node = event(run_id);
1819        node.seq = 2;
1820        node.kind = "node.created".into();
1821        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1822        node.data = serde_json::json!({
1823            "kind": "spinoff", "source_branch": "main"
1824        });
1825        apply_event(&paths, &node).unwrap();
1826
1827        assert_eq!(
1828            read_manifest_opt(&paths)
1829                .unwrap()
1830                .unwrap()
1831                .source_branch
1832                .as_deref(),
1833            Some("release")
1834        );
1835    }
1836
1837    /// Bootstrap a run with a single `n-0001` node via the event-sourced path,
1838    /// returning its paths. Shared by the supervisor-state replay tests below.
1839    fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1840        let rid = RunId::parse_str(run_id).unwrap();
1841        let dir = crate::run_dir(tmp.path(), &rid);
1842        std::fs::create_dir_all(&dir).unwrap();
1843        let paths = RunPaths::new(dir, run_id).unwrap();
1844
1845        let mut created = event(run_id);
1846        created.kind = "run.created".into();
1847        created.data = serde_json::json!({
1848            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1849        });
1850        apply_event(&paths, &created).expect("run.created applies");
1851
1852        let mut node = event(run_id);
1853        node.seq = 2;
1854        node.kind = "node.created".into();
1855        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1856        node.data = serde_json::json!({ "kind": "spinoff" });
1857        apply_event(&paths, &node).expect("node.created applies");
1858        paths
1859    }
1860
1861    fn read_n0001(paths: &RunPaths) -> Node {
1862        read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
1863            .unwrap()
1864            .unwrap()
1865    }
1866
1867    #[test]
1868    fn awaiting_input_clock_is_durable_first_write_wins_and_resolve_is_fenced() {
1869        let tmp = TempDir::new().unwrap();
1870        let run_id = "01jxwd0000000000000000000w";
1871        let paths = seed_run_with_node(&tmp, run_id);
1872        let nid = Some(NodeId::parse_str("n-0001").unwrap());
1873        let opened_at: chrono::DateTime<Utc> = "2026-08-16T12:00:00Z".parse().unwrap();
1874        let mut open = event(run_id);
1875        open.seq = 3;
1876        open.ts = opened_at;
1877        open.kind = "node.awaiting_input".into();
1878        open.node_id = nid.clone();
1879        open.data = serde_json::json!({ "discussion_items": [{
1880            "topic": "Which scope?",
1881            "options": ["small", "large"],
1882            "recommended_default": "small"
1883        }] });
1884        apply_event(&paths, &open).unwrap();
1885        let first = read_n0001(&paths).awaiting_input.unwrap();
1886        assert_eq!(first.opened_at, opened_at);
1887        assert_eq!(first.event_seq, 3);
1888
1889        // A duplicate/restarted supervisor observation cannot restart the clock.
1890        let mut duplicate = open.clone();
1891        duplicate.seq = 4;
1892        duplicate.ts = opened_at + chrono::Duration::hours(1);
1893        apply_event(&paths, &duplicate).unwrap();
1894        let still_first = read_n0001(&paths).awaiting_input.unwrap();
1895        assert_eq!(still_first.opened_at, opened_at);
1896        assert_eq!(still_first.event_seq, 3);
1897
1898        // A stale timeout for another generation cannot clear this request.
1899        let mut stale = event(run_id);
1900        stale.seq = 5;
1901        stale.kind = "node.input_resolved".into();
1902        stale.node_id = nid.clone();
1903        stale.data = serde_json::json!({ "event_seq": 2 });
1904        apply_event(&paths, &stale).unwrap();
1905        assert!(read_n0001(&paths).awaiting_input.is_some());
1906
1907        let mut resolved = stale;
1908        resolved.seq = 6;
1909        resolved.data = serde_json::json!({ "event_seq": 3 });
1910        apply_event(&paths, &resolved).unwrap();
1911        assert!(read_n0001(&paths).awaiting_input.is_none());
1912    }
1913
1914    #[test]
1915    fn awaiting_input_rejects_missing_default_without_mutating_projection() {
1916        let tmp = TempDir::new().unwrap();
1917        let run_id = "01jxwd0000000000000000000x";
1918        let paths = seed_run_with_node(&tmp, run_id);
1919        let mut open = event(run_id);
1920        open.seq = 3;
1921        open.kind = "node.awaiting_input".into();
1922        open.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1923        open.data = serde_json::json!({ "discussion_items": [{
1924            "topic": "Which scope?", "options": ["small", "large"]
1925        }] });
1926        assert!(reduce_event_to_ops(&paths, &open).is_err());
1927        assert!(read_n0001(&paths).awaiting_input.is_none());
1928    }
1929
1930    #[test]
1931    fn awaiting_input_validation_is_state_independent_and_worker_exit_clears_it() {
1932        let tmp = TempDir::new().unwrap();
1933        let run_id = "01jxwd0000000000000000000y";
1934        let paths = seed_run_with_node(&tmp, run_id);
1935        let nid = Some(NodeId::parse_str("n-0001").unwrap());
1936
1937        let mut open = event(run_id);
1938        open.seq = 3;
1939        open.kind = "node.awaiting_input".into();
1940        open.node_id = nid.clone();
1941        open.data = serde_json::json!({ "discussion_items": [{
1942            "topic": "Which scope?", "options": ["small", "large"],
1943            "recommended_default": "small"
1944        }] });
1945        apply_event(&paths, &open).unwrap();
1946
1947        let mut malformed_duplicate = open.clone();
1948        malformed_duplicate.seq = 4;
1949        malformed_duplicate.data = serde_json::json!({ "discussion_items": [] });
1950        assert!(reduce_event_to_ops(&paths, &malformed_duplicate).is_err());
1951
1952        let mut exited = event(run_id);
1953        exited.seq = 5;
1954        exited.kind = "worker.exited".into();
1955        exited.node_id = nid;
1956        exited.data = serde_json::json!({ "exit_code": 0 });
1957        apply_event(&paths, &exited).unwrap();
1958        let node = read_n0001(&paths);
1959        assert!(node.awaiting_input.is_none());
1960        assert!(node.worker_exit.is_some());
1961
1962        let mut delayed_open = open;
1963        delayed_open.seq = 6;
1964        assert!(reduce_event_to_ops(&paths, &delayed_open)
1965            .unwrap()
1966            .is_empty());
1967    }
1968
1969    #[test]
1970    fn input_resolved_requires_generation_even_when_nothing_is_open() {
1971        let tmp = TempDir::new().unwrap();
1972        let run_id = "01jxwd0000000000000000000z";
1973        let paths = seed_run_with_node(&tmp, run_id);
1974        let mut resolved = event(run_id);
1975        resolved.seq = 3;
1976        resolved.kind = "node.input_resolved".into();
1977        resolved.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1978        resolved.data = serde_json::json!({});
1979        assert!(reduce_event_to_ops(&paths, &resolved).is_err());
1980    }
1981
1982    #[test]
1983    fn awaiting_input_default_must_be_one_of_options() {
1984        let tmp = TempDir::new().unwrap();
1985        let run_id = "01jxwd00000000000000000010";
1986        let paths = seed_run_with_node(&tmp, run_id);
1987        let mut open = event(run_id);
1988        open.seq = 3;
1989        open.kind = "node.awaiting_input".into();
1990        open.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1991        open.data = serde_json::json!({ "discussion_items": [{
1992            "topic": "Which scope?", "options": ["small", "large"],
1993            "recommended_default": "other"
1994        }] });
1995        assert!(reduce_event_to_ops(&paths, &open).is_err());
1996    }
1997
1998    fn merge_started_event(run_id: &str, seq: u64, op_id: &str, expected: &str) -> Event {
1999        let mut ev = event(run_id);
2000        ev.seq = seq;
2001        ev.kind = KIND_MERGE_STARTED.into();
2002        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2003        ev.data = serde_json::json!({
2004            "op_id": op_id,
2005            "source_branch": "main",
2006            "worker_branch": "wt/worker",
2007            "expected_source_oid": expected,
2008            "worker_oid": "cafebabecafebabecafebabecafebabecafebabe",
2009            "base_sha": null,
2010            "driver_pid": 4242,
2011            "driver_pid_start_secs": null,
2012            "started_at": "2026-08-15T00:00:00Z",
2013        });
2014        ev
2015    }
2016
2017    /// `merge.started` records the in-flight transaction on `pending_merge`
2018    /// without transitioning the node's status.
2019    #[test]
2020    fn merge_started_records_pending_transaction() {
2021        let tmp = TempDir::new().unwrap();
2022        let run_id = "01jxsnap000000000000000000";
2023        let paths = seed_run_with_node(&tmp, run_id);
2024
2025        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
2026        let n = read_n0001(&paths);
2027        assert_eq!(
2028            n.status,
2029            Status::Pending,
2030            "recording a merge is not terminal"
2031        );
2032        let txn = n.pending_merge.expect("transaction recorded");
2033        assert_eq!(txn.op_id, "op-1");
2034        assert_eq!(txn.expected_source_oid, "aaa");
2035    }
2036
2037    /// `merge.aborted` clears the pending transaction it names, leaving the node
2038    /// live; a stale abort for a different `op_id` is a clean no-op.
2039    #[test]
2040    fn merge_aborted_clears_matching_transaction_only() {
2041        let tmp = TempDir::new().unwrap();
2042        let run_id = "01jxsnap000000000000000000";
2043        let paths = seed_run_with_node(&tmp, run_id);
2044        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
2045
2046        // A stale abort for a different op_id does nothing.
2047        let mut stale = event(run_id);
2048        stale.seq = 4;
2049        stale.kind = KIND_MERGE_ABORTED.into();
2050        stale.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2051        stale.data = serde_json::json!({ "op_id": "op-OTHER", "reason": "x" });
2052        apply_event(&paths, &stale).unwrap();
2053        assert!(
2054            read_n0001(&paths).pending_merge.is_some(),
2055            "stale abort is a no-op"
2056        );
2057
2058        // The matching abort clears it; the node stays live.
2059        let mut abort = event(run_id);
2060        abort.seq = 5;
2061        abort.kind = KIND_MERGE_ABORTED.into();
2062        abort.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2063        abort.data = serde_json::json!({ "op_id": "op-1", "reason": "no mutation" });
2064        apply_event(&paths, &abort).unwrap();
2065        let n = read_n0001(&paths);
2066        assert!(
2067            n.pending_merge.is_none(),
2068            "matching abort clears the transaction"
2069        );
2070        assert_eq!(n.status, Status::Pending, "abort does not terminalize");
2071    }
2072
2073    /// A terminal `node.report` (the normal, no-crash completion) clears any
2074    /// pending merge transaction.
2075    #[test]
2076    fn terminal_report_clears_pending_merge() {
2077        let tmp = TempDir::new().unwrap();
2078        let run_id = "01jxsnap000000000000000000";
2079        let paths = seed_run_with_node(&tmp, run_id);
2080        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
2081
2082        let mut report = event(run_id);
2083        report.seq = 4;
2084        report.kind = "node.report".into();
2085        report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2086        report.data = serde_json::json!({ "success": true, "via": "explicit-merge" });
2087        apply_event(&paths, &report).unwrap();
2088        let n = read_n0001(&paths);
2089        assert_eq!(n.status, Status::Done);
2090        assert!(
2091            n.pending_merge.is_none(),
2092            "completed merge clears the transaction"
2093        );
2094    }
2095
2096    /// Regression (issue `retire-via-string`): the terminal-node adoption
2097    /// exception now keys on the typed `RunMerge` origin, NOT a forgeable `via`
2098    /// string. A late report against a `Failed` node that carries an `Agent`
2099    /// origin (as every `node report` self-submission does) plus a forged
2100    /// `via: "explicit-merge"` must NOT be adopted — the node stays `Failed`. A
2101    /// present-but-malformed origin is likewise not adopted. Only a genuine
2102    /// `RunMerge`-origin report (or a legacy report with NO origin field) is
2103    /// adopted and corrects the node to `Done`.
2104    #[test]
2105    fn late_merge_adoption_requires_run_merge_origin_not_forged_via() {
2106        let tmp = TempDir::new().unwrap();
2107
2108        // Helper: seed a fresh run (distinct id), drive n-0001 to Failed, apply a
2109        // late report, and return the resulting node status.
2110        let drive = |run_id: &str, report_data: Value| -> Status {
2111            let paths = seed_run_with_node(&tmp, run_id);
2112            // Terminalize the node as Failed (a watchdog-synthesized failure).
2113            let mut fail = event(run_id);
2114            fail.seq = 3;
2115            fail.kind = "node.status".into();
2116            fail.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2117            fail.data = serde_json::json!({ "status": "failed" });
2118            apply_event(&paths, &fail).unwrap();
2119            assert_eq!(read_n0001(&paths).status, Status::Failed);
2120            // The late report under test.
2121            let mut report = event(run_id);
2122            report.seq = 4;
2123            report.kind = "node.report".into();
2124            report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2125            report.data = report_data;
2126            apply_event(&paths, &report).unwrap();
2127            read_n0001(&paths).status
2128        };
2129
2130        // Forged: Agent origin + a hand-set `via` — NOT adopted, stays Failed.
2131        let mut agent_forged = serde_json::json!({ "success": true, "via": "explicit-merge" });
2132        crate::ReportOrigin::Agent.stamp(&mut agent_forged);
2133        assert_eq!(
2134            drive("01jxsnap000000000000000001", agent_forged),
2135            Status::Failed,
2136            "an Agent-origin report with a forged via must not be adopted"
2137        );
2138
2139        // Present-but-malformed origin + forged via — NOT adopted, stays Failed.
2140        let malformed = serde_json::json!({
2141            "success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
2142        });
2143        assert_eq!(
2144            drive("01jxsnap000000000000000002", malformed),
2145            Status::Failed,
2146            "a malformed origin must not re-unlock the legacy via adoption path"
2147        );
2148
2149        // Genuine RunMerge origin (no `via` at all) — adopted, corrected to Done.
2150        let mut run_merge = serde_json::json!({ "success": true });
2151        crate::ReportOrigin::RunMerge {
2152            op_id: Some("op-1".into()),
2153            worker_oid: Some("cafebabe".into()),
2154        }
2155        .stamp(&mut run_merge);
2156        assert_eq!(
2157            drive("01jxsnap000000000000000003", run_merge),
2158            Status::Done,
2159            "a genuine RunMerge-origin report is adopted and corrects Failed→Done"
2160        );
2161
2162        // Legacy report (no origin field) with `via` — still adopted (backward
2163        // compat with pre-typed-origin on-disk runs).
2164        let legacy = serde_json::json!({ "success": true, "via": "explicit-merge" });
2165        assert_eq!(
2166            drive("01jxsnap000000000000000004", legacy),
2167            Status::Done,
2168            "a legacy via-only report (no origin field) is still adopted"
2169        );
2170    }
2171
2172    /// A terminal `node.status` (e.g. a watchdog-synthesized failure) clears any
2173    /// in-flight merge transaction, so `pending_merge` is never stranded on a
2174    /// terminal node where recovery would refuse to look (/llm-review finding).
2175    #[test]
2176    fn terminal_node_status_clears_pending_merge() {
2177        let tmp = TempDir::new().unwrap();
2178        let run_id = "01jxsnap000000000000000000";
2179        let paths = seed_run_with_node(&tmp, run_id);
2180        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
2181        assert!(read_n0001(&paths).pending_merge.is_some());
2182
2183        let mut status = event(run_id);
2184        status.seq = 4;
2185        status.kind = "node.status".into();
2186        status.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2187        status.data = serde_json::json!({ "status": "failed" });
2188        apply_event(&paths, &status).unwrap();
2189        let n = read_n0001(&paths);
2190        assert_eq!(n.status, Status::Failed);
2191        assert!(
2192            n.pending_merge.is_none(),
2193            "terminal status clears the transaction"
2194        );
2195    }
2196
2197    /// Replaying `supervisor.attached` from scratch reproduces
2198    /// `Node.supervisor_pid` — the field is now event-sourced, not a
2199    /// projection-only write (issue `supervisor-state-not-event-sourced`).
2200    #[test]
2201    fn supervisor_attached_sets_supervisor_pid() {
2202        let tmp = TempDir::new().unwrap();
2203        let run_id = "01jxsnap000000000000000000";
2204        let paths = seed_run_with_node(&tmp, run_id);
2205        assert_eq!(read_n0001(&paths).supervisor_pid, None);
2206
2207        let mut ev = event(run_id);
2208        ev.seq = 3;
2209        ev.kind = "supervisor.attached".into();
2210        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2211        ev.data = serde_json::json!({ "pid": 47820 });
2212        apply_event(&paths, &ev).expect("supervisor.attached applies");
2213        assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
2214    }
2215
2216    /// A second attach with a different pid overrides (latest-wins); a replay
2217    /// of the *same* pid is a pure no-op that does not churn `updated_at`.
2218    #[test]
2219    fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
2220        let tmp = TempDir::new().unwrap();
2221        let run_id = "01jxsnap000000000000000000";
2222        let paths = seed_run_with_node(&tmp, run_id);
2223
2224        let mut ev = event(run_id);
2225        ev.kind = "supervisor.attached".into();
2226        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2227
2228        ev.seq = 3;
2229        ev.data = serde_json::json!({ "pid": 100 });
2230        apply_event(&paths, &ev).expect("first attach applies");
2231        assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
2232
2233        // A restart binds a fresh pid: latest-wins.
2234        ev.seq = 4;
2235        ev.data = serde_json::json!({ "pid": 200 });
2236        apply_event(&paths, &ev).expect("second attach applies");
2237        let after_second = read_n0001(&paths);
2238        assert_eq!(after_second.supervisor_pid, Some(200));
2239
2240        // Replaying the latest event again is a no-op: the planned ops are
2241        // empty and the projection bytes (including `updated_at`) are unchanged.
2242        let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
2243        assert!(ops.is_empty(), "re-applying same pid must plan no ops");
2244        apply_event(&paths, &ev).expect("replay applies as no-op");
2245        assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
2246    }
2247
2248    /// Replaying `supervisor.cursor_advanced` from scratch reproduces
2249    /// `Node.last_processed_report_seq_by_child`.
2250    #[test]
2251    fn supervisor_cursor_advanced_sets_report_cursor() {
2252        let tmp = TempDir::new().unwrap();
2253        let run_id = "01jxsnap000000000000000000";
2254        let paths = seed_run_with_node(&tmp, run_id);
2255        let child = "02jxsnap000000000000000000";
2256
2257        let mut ev = event(run_id);
2258        ev.seq = 3;
2259        ev.kind = "supervisor.cursor_advanced".into();
2260        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2261        ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
2262        apply_event(&paths, &ev).expect("cursor_advanced applies");
2263
2264        let n = read_n0001(&paths);
2265        assert_eq!(
2266            n.last_processed_report_seq_by_child.get(child),
2267            Some(&Value::from(7u64))
2268        );
2269    }
2270
2271    /// The cursor is monotonic and idempotent: re-applying the same
2272    /// `(child_run_id, report_seq)` is a no-op, an older seq never moves the
2273    /// cursor backward, and a higher seq advances it. A second distinct child
2274    /// gets its own independent entry.
2275    #[test]
2276    fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
2277        let tmp = TempDir::new().unwrap();
2278        let run_id = "01jxsnap000000000000000000";
2279        let paths = seed_run_with_node(&tmp, run_id);
2280        let child_a = "02jxsnap000000000000000000";
2281        let child_b = "03jxsnap000000000000000000";
2282
2283        let mut ev = event(run_id);
2284        ev.kind = "supervisor.cursor_advanced".into();
2285        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2286
2287        ev.seq = 3;
2288        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
2289        apply_event(&paths, &ev).expect("seq 5 applies");
2290
2291        // Replay the exact same event — no-op, plans zero ops.
2292        let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
2293        assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
2294
2295        // An older seq must not move the cursor backward.
2296        ev.seq = 4;
2297        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
2298        let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
2299        assert!(ops.is_empty(), "older seq must plan no ops");
2300        apply_event(&paths, &ev).expect("older seq applies as no-op");
2301        assert_eq!(
2302            read_n0001(&paths)
2303                .last_processed_report_seq_by_child
2304                .get(child_a),
2305            Some(&Value::from(5u64))
2306        );
2307
2308        // A higher seq advances; an independent child gets its own entry.
2309        ev.seq = 5;
2310        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
2311        apply_event(&paths, &ev).expect("higher seq applies");
2312        ev.seq = 6;
2313        ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
2314        apply_event(&paths, &ev).expect("second child applies");
2315
2316        let n = read_n0001(&paths);
2317        assert_eq!(
2318            n.last_processed_report_seq_by_child.get(child_a),
2319            Some(&Value::from(9u64))
2320        );
2321        assert_eq!(
2322            n.last_processed_report_seq_by_child.get(child_b),
2323            Some(&Value::from(1u64))
2324        );
2325    }
2326
2327    /// Both new kinds reject a malformed payload at the reducer boundary so a
2328    /// forged event can never write a corrupt projection.
2329    #[test]
2330    fn supervisor_state_events_reject_malformed_payloads() {
2331        let tmp = TempDir::new().unwrap();
2332        let run_id = "01jxsnap000000000000000000";
2333        let paths = seed_run_with_node(&tmp, run_id);
2334        let nid = Some(NodeId::parse_str("n-0001").unwrap());
2335
2336        // Missing pid.
2337        let mut ev = event(run_id);
2338        ev.seq = 3;
2339        ev.kind = "supervisor.attached".into();
2340        ev.node_id = nid.clone();
2341        ev.data = serde_json::json!({});
2342        assert!(matches!(
2343            reduce_event_to_ops(&paths, &ev),
2344            Err(Error::CorruptEventLog { .. })
2345        ));
2346
2347        // Missing envelope node_id.
2348        ev.node_id = None;
2349        ev.data = serde_json::json!({ "pid": 1 });
2350        assert!(matches!(
2351            reduce_event_to_ops(&paths, &ev),
2352            Err(Error::CorruptEventLog { .. })
2353        ));
2354
2355        // cursor_advanced: malformed child_run_id.
2356        let mut ev2 = event(run_id);
2357        ev2.seq = 4;
2358        ev2.kind = "supervisor.cursor_advanced".into();
2359        ev2.node_id = nid.clone();
2360        ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
2361        assert!(matches!(
2362            reduce_event_to_ops(&paths, &ev2),
2363            Err(Error::CorruptEventLog { .. })
2364        ));
2365
2366        // cursor_advanced: missing report_seq.
2367        ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
2368        assert!(matches!(
2369            reduce_event_to_ops(&paths, &ev2),
2370            Err(Error::CorruptEventLog { .. })
2371        ));
2372    }
2373
2374    /// The append gate stays fail-closed after the 0.2 cut added
2375    /// `Kind`'s `#[serde(other)]` catch-all: a `run.created` / `node.created`
2376    /// whose `kind` is a removed kind (`code`, …) or plain garbage must still be
2377    /// rejected as `CorruptEventLog`, NOT silently accepted as `Kind::Unknown`.
2378    /// (Legacy runs are never re-created through the reducer — their manifest is
2379    /// read directly from disk via the permissive `Kind::Unknown` decode.)
2380    #[test]
2381    fn removed_or_garbage_kind_in_created_events_is_rejected() {
2382        let tmp = TempDir::new().unwrap();
2383        let run_id = "01jxsnap000000000000000000";
2384        let rid = RunId::parse_str(run_id).unwrap();
2385        let dir = crate::run_dir(tmp.path(), &rid);
2386        std::fs::create_dir_all(&dir).unwrap();
2387        let paths = RunPaths::new(dir, run_id).unwrap();
2388
2389        for bad in ["code", "orchestrate", "bugfix", "make-skill", "garbage"] {
2390            let mut ev = event(run_id);
2391            ev.kind = "run.created".into();
2392            ev.node_id = None;
2393            ev.data = serde_json::json!({ "kind": bad, "lifecycle": "autonomous", "title": "t" });
2394            assert!(
2395                matches!(
2396                    reduce_event_to_ops(&paths, &ev),
2397                    Err(Error::CorruptEventLog { .. })
2398                ),
2399                "run.created with kind {bad:?} must be rejected, not folded to Unknown"
2400            );
2401        }
2402
2403        // A surviving creatable kind still folds cleanly (guards against a
2404        // false positive that rejects everything).
2405        let mut ok = event(run_id);
2406        ok.kind = "run.created".into();
2407        ok.node_id = None;
2408        ok.data = serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
2409        assert!(reduce_event_to_ops(&paths, &ok).is_ok());
2410    }
2411
2412    /// Snapshot every projection file under `paths` to a `path → inode` map.
2413    ///
2414    /// An atomic projection write is temp-file + rename, so a rewritten file
2415    /// always lands a *fresh inode* — even when its bytes are byte-for-byte
2416    /// identical (e.g. a manifest op that refreshes `updated_at` to the same
2417    /// timestamp). Comparing inodes therefore detects every write the reducer
2418    /// makes, with no false negatives a content diff would suffer. `events.jsonl`
2419    /// and `.lock` are excluded: `apply_event` never touches them.
2420    #[cfg(unix)]
2421    fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
2422        use std::os::unix::fs::MetadataExt;
2423        let mut consider = vec![paths.manifest()];
2424        for dir in [paths.nodes_dir()] {
2425            if let Ok(rd) = std::fs::read_dir(&dir) {
2426                for ent in rd.flatten() {
2427                    let p = ent.path();
2428                    if p.extension().and_then(|s| s.to_str()) == Some("json") {
2429                        consider.push(p);
2430                    }
2431                }
2432            }
2433        }
2434        let mut map = std::collections::BTreeMap::new();
2435        for p in consider {
2436            if let Ok(md) = std::fs::symlink_metadata(&p) {
2437                if md.file_type().is_file() {
2438                    map.insert(p, md.ino());
2439                }
2440            }
2441        }
2442        map
2443    }
2444
2445    /// The exhaustive parity guarantee `projected-paths-into-reducer` requires:
2446    /// for an event applied against a given state, the paths
2447    /// [`plan_projections`] reports MUST equal the files [`apply_event`]
2448    /// actually writes. Plan first (against pre-apply state), apply, then diff
2449    /// the projection inodes — a file is "written" iff it is newly present or
2450    /// its inode changed. `expect_writes` guards the test itself: when set, the
2451    /// touched set must be non-empty, so a kind that silently stopped writing
2452    /// can't pass by matching an empty plan against an empty diff.
2453    #[cfg(unix)]
2454    fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
2455        use std::collections::BTreeSet;
2456        let before = projection_inodes(paths);
2457        let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
2458            .unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
2459            .into_iter()
2460            .collect();
2461        apply_event(paths, ev)
2462            .unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
2463        let after = projection_inodes(paths);
2464        let touched: BTreeSet<PathBuf> = after
2465            .iter()
2466            .filter(|(p, ino)| before.get(*p) != Some(*ino))
2467            .map(|(p, _)| p.clone())
2468            .collect();
2469        assert_eq!(
2470            planned, touched,
2471            "kind={}: plan_projections must name exactly the files apply_event writes",
2472            ev.kind
2473        );
2474        if expect_writes {
2475            assert!(
2476                !touched.is_empty(),
2477                "kind={}: expected this event to write at least one projection",
2478                ev.kind
2479            );
2480        }
2481    }
2482
2483    /// Drive every event kind through a dependency-ordered lifecycle on real
2484    /// runs, asserting plan/apply parity at each step. Covers the writing kinds
2485    /// (run/node/supervisor/child) in states where they
2486    /// project, plus the no-op kinds (audit records, `supervisor.exited`,
2487    /// terminal-guarded transitions) where both the plan and the apply touch
2488    /// nothing.
2489    #[cfg(unix)]
2490    #[test]
2491    fn plan_projections_matches_apply_for_every_kind() {
2492        let tmp = TempDir::new().unwrap();
2493        let run_id = "01jxsnap000000000000000000";
2494        let rid = RunId::parse_str(run_id).unwrap();
2495        let dir = crate::run_dir(tmp.path(), &rid);
2496        std::fs::create_dir_all(&dir).unwrap();
2497        let paths = RunPaths::new(dir, run_id).unwrap();
2498        let nid = || Some(NodeId::parse_str("n-0001").unwrap());
2499        let child = "02jxsnap000000000000000000";
2500
2501        // Helper to build a fresh envelope at a monotonic seq.
2502        let mut next_seq = 0u64;
2503        let mut at = |kind: &str, node_id, data| {
2504            next_seq += 1;
2505            Event {
2506                ts: Utc::now(),
2507                seq: next_seq,
2508                kind: kind.into(),
2509                run_id: rid.clone(),
2510                node_id,
2511                idempotency_key: None,
2512                data,
2513            }
2514        };
2515
2516        // run.created → manifest.json
2517        assert_plan_matches_apply(
2518            &paths,
2519            &at(
2520                "run.created",
2521                None,
2522                serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
2523            ),
2524            true,
2525        );
2526        // run.status (pending → running) → manifest.json
2527        assert_plan_matches_apply(
2528            &paths,
2529            &at(
2530                "run.status",
2531                None,
2532                serde_json::json!({ "status": "running" }),
2533            ),
2534            true,
2535        );
2536        // node.created → nodes/n-0001.json + manifest.json
2537        assert_plan_matches_apply(
2538            &paths,
2539            &at(
2540                "node.created",
2541                nid(),
2542                serde_json::json!({ "kind": "spinoff" }),
2543            ),
2544            true,
2545        );
2546        // node.status (pending → running) → nodes/n-0001.json
2547        assert_plan_matches_apply(
2548            &paths,
2549            &at(
2550                "node.status",
2551                nid(),
2552                serde_json::json!({ "status": "running" }),
2553            ),
2554            true,
2555        );
2556        // supervisor.attached → nodes/n-0001.json (still non-terminal)
2557        assert_plan_matches_apply(
2558            &paths,
2559            &at(
2560                "supervisor.attached",
2561                nid(),
2562                serde_json::json!({ "pid": 4242 }),
2563            ),
2564            true,
2565        );
2566        // supervisor.cursor_advanced → nodes/n-0001.json
2567        assert_plan_matches_apply(
2568            &paths,
2569            &at(
2570                "supervisor.cursor_advanced",
2571                nid(),
2572                serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
2573            ),
2574            true,
2575        );
2576        // child.spawned → nodes/n-0001.json (parent node)
2577        assert_plan_matches_apply(
2578            &paths,
2579            &at(
2580                "child.spawned",
2581                nid(),
2582                serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
2583            ),
2584            true,
2585        );
2586        // node.report success → nodes/n-0001.json (now terminal)
2587        assert_plan_matches_apply(
2588            &paths,
2589            &at("node.report", nid(), serde_json::json!({ "success": true })),
2590            true,
2591        );
2592        // Terminal-guarded no-ops: a settled node swallows further transitions,
2593        // so both the plan and the apply touch nothing.
2594        assert_plan_matches_apply(
2595            &paths,
2596            &at(
2597                "node.status",
2598                nid(),
2599                serde_json::json!({ "status": "failed" }),
2600            ),
2601            false,
2602        );
2603        // No-op audit / lifecycle kinds: zero projections by design.
2604        for kind in [
2605            "supervisor.exited",
2606            "orchestrator.decision",
2607            "discuss.critical",
2608            "cleanup.window_missing",
2609        ] {
2610            assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
2611        }
2612    }
2613
2614    /// A `worker.exited` carrying a clean `exit_code: 0` folds onto the node's
2615    /// `worker_exit` field as a clean exit — and does NOT transition `status`
2616    /// (terminalization is the supervisor's decision via the typed table).
2617    #[test]
2618    fn worker_exited_records_clean_exit_without_transitioning_status() {
2619        let tmp = TempDir::new().unwrap();
2620        let run_id = "01jxsnap000000000000000000";
2621        let paths = bootstrap_retry_node(&tmp, run_id);
2622
2623        let mut ev = event(run_id);
2624        ev.seq = 3;
2625        ev.kind = "worker.exited".into();
2626        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2627        ev.data = serde_json::json!({ "exit_code": 0 });
2628        apply_event(&paths, &ev).expect("worker.exited applies");
2629
2630        let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
2631            .unwrap()
2632            .unwrap();
2633        let exit = n.worker_exit.expect("worker_exit recorded");
2634        assert_eq!(exit.code, Some(0));
2635        assert_eq!(exit.signal, None);
2636        assert!(exit.is_clean());
2637        assert_eq!(
2638            n.status,
2639            Status::Pending,
2640            "the exit fact never transitions status"
2641        );
2642    }
2643
2644    /// A `worker.exited` carrying a `signal` records it as a failure; and the fold
2645    /// is first-write-wins — a replayed/duplicate exit event never overwrites the
2646    /// first recorded fact (replay-safety for the `applied_seq` watermark).
2647    #[test]
2648    fn worker_exited_records_signal_and_is_first_write_wins() {
2649        let tmp = TempDir::new().unwrap();
2650        let run_id = "01jxsnap000000000000000000";
2651        let paths = bootstrap_retry_node(&tmp, run_id);
2652        let nid = NodeId::parse_str("n-0001").unwrap();
2653
2654        let mut ev = event(run_id);
2655        ev.seq = 3;
2656        ev.kind = "worker.exited".into();
2657        ev.node_id = Some(nid.clone());
2658        ev.data = serde_json::json!({ "signal": 9 });
2659        apply_event(&paths, &ev).expect("worker.exited applies");
2660
2661        let n = read_node_opt(&paths, &nid).unwrap().unwrap();
2662        let exit = n.worker_exit.expect("worker_exit recorded");
2663        assert_eq!(exit.signal, Some(9));
2664        assert!(exit.is_failure());
2665
2666        // A later, conflicting exit event (e.g. a replay of a different value) is a
2667        // clean no-op: the first fact stands.
2668        let mut dup = event(run_id);
2669        dup.seq = 4;
2670        dup.kind = "worker.exited".into();
2671        dup.node_id = Some(nid.clone());
2672        dup.data = serde_json::json!({ "exit_code": 0 });
2673        apply_event(&paths, &dup).expect("duplicate worker.exited applies as no-op");
2674        let n2 = read_node_opt(&paths, &nid).unwrap().unwrap();
2675        assert_eq!(
2676            n2.worker_exit.unwrap().signal,
2677            Some(9),
2678            "first-write-wins: the replayed exit must not overwrite the recorded fact"
2679        );
2680    }
2681
2682    /// A `worker.exited` carrying neither `exit_code` nor `signal` is malformed —
2683    /// the reducer is the canonical gate and rejects it as `CorruptEventLog` rather
2684    /// than record an empty fact.
2685    #[test]
2686    fn worker_exited_without_code_or_signal_is_corrupt() {
2687        let tmp = TempDir::new().unwrap();
2688        let run_id = "01jxsnap000000000000000000";
2689        let paths = bootstrap_retry_node(&tmp, run_id);
2690
2691        let mut ev = event(run_id);
2692        ev.seq = 3;
2693        ev.kind = "worker.exited".into();
2694        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2695        ev.data = serde_json::json!({});
2696        match reduce_event_to_ops(&paths, &ev) {
2697            Err(Error::CorruptEventLog { .. }) => {}
2698            Ok(_) => panic!("an empty worker.exited payload must be rejected, not applied"),
2699            Err(other) => panic!("expected CorruptEventLog, got {other:?}"),
2700        }
2701
2702        // Carrying BOTH is contradictory (a process cannot both return a code and
2703        // be killed) — also rejected.
2704        ev.data = serde_json::json!({ "exit_code": 0, "signal": 9 });
2705        match reduce_event_to_ops(&paths, &ev) {
2706            Err(Error::CorruptEventLog { .. }) => {}
2707            Ok(_) => panic!("a worker.exited with both fields must be rejected"),
2708            Err(other) => panic!("expected CorruptEventLog, got {other:?}"),
2709        }
2710    }
2711
2712    /// `node.death_observed` records the residual crash backstop's first-death
2713    /// anchor (`first_death_at`) as `ev.ts`, is **first-write-wins** (a later
2714    /// re-observation never resets the monotonic anchor), and is a no-op against a
2715    /// terminal node (the backstop is moot once settled). Issue
2716    /// `typed-supervisor-outcomes`.
2717    #[test]
2718    fn node_death_observed_records_first_death_first_write_wins() {
2719        let tmp = TempDir::new().unwrap();
2720        let run_id = "01jxsnap000000000000000000";
2721        let paths = bootstrap_retry_node(&tmp, run_id);
2722        let nid = NodeId::parse_str("n-0001").unwrap();
2723
2724        let mut ev = event(run_id);
2725        ev.seq = 3;
2726        ev.kind = "node.death_observed".into();
2727        ev.node_id = Some(nid.clone());
2728        ev.data = serde_json::json!({});
2729        apply_event(&paths, &ev).expect("node.death_observed applies");
2730        let first = read_node_opt(&paths, &nid)
2731            .unwrap()
2732            .unwrap()
2733            .first_death_at
2734            .expect("first_death_at recorded");
2735        assert_eq!(first, ev.ts, "the anchor is the event's own timestamp");
2736
2737        // A later re-observation is first-write-wins: the monotonic anchor holds.
2738        let mut later = event(run_id);
2739        later.seq = 4;
2740        later.kind = "node.death_observed".into();
2741        later.node_id = Some(nid.clone());
2742        later.ts = ev.ts + chrono::Duration::seconds(30);
2743        later.data = serde_json::json!({});
2744        apply_event(&paths, &later).expect("re-observation applies as no-op");
2745        assert_eq!(
2746            read_node_opt(&paths, &nid).unwrap().unwrap().first_death_at,
2747            Some(first),
2748            "first-write-wins: a re-observation must not reset the anchor"
2749        );
2750    }
2751
2752    /// `node.death_observed` is a no-op once a higher-fidelity fact exists — here a
2753    /// told `worker.exited` — so a from-scratch replay converges to the same state
2754    /// the supervisor's lock-guarded emitter would produce (the backstop is moot
2755    /// once the shim recorded a real exit). Issue `typed-supervisor-outcomes`.
2756    #[test]
2757    fn node_death_observed_noop_when_worker_exit_present() {
2758        let tmp = TempDir::new().unwrap();
2759        let run_id = "01jxsnap000000000000000000";
2760        let paths = bootstrap_retry_node(&tmp, run_id);
2761        let nid = NodeId::parse_str("n-0001").unwrap();
2762
2763        // A told exit lands first.
2764        let mut exit = event(run_id);
2765        exit.seq = 3;
2766        exit.kind = "worker.exited".into();
2767        exit.node_id = Some(nid.clone());
2768        exit.data = serde_json::json!({ "exit_code": 0 });
2769        apply_event(&paths, &exit).unwrap();
2770
2771        // A death observation for the same node folds to nothing.
2772        let mut death = event(run_id);
2773        death.seq = 4;
2774        death.kind = "node.death_observed".into();
2775        death.node_id = Some(nid.clone());
2776        death.data = serde_json::json!({});
2777        apply_event(&paths, &death).expect("applies as no-op");
2778        assert_eq!(
2779            read_node_opt(&paths, &nid).unwrap().unwrap().first_death_at,
2780            None,
2781            "a told worker.exited makes the crash backstop moot; no anchor recorded"
2782        );
2783    }
2784
2785    /// `node.retry` clears the previous attempt's told exit fact: the re-spawned
2786    /// worker is a NEW process, so a stale `worker_exit` must not carry over (it
2787    /// would make the supervisor mis-judge the fresh attempt from the dead one's
2788    /// exit). Issue `thin-exit-status-launcher`.
2789    #[test]
2790    fn node_retry_clears_worker_exit() {
2791        let tmp = TempDir::new().unwrap();
2792        let run_id = "01jxsnap000000000000000000";
2793        let paths = bootstrap_retry_node(&tmp, run_id);
2794        let nid = NodeId::parse_str("n-0001").unwrap();
2795
2796        // Record a failing exit on the first attempt.
2797        let mut exit = event(run_id);
2798        exit.seq = 3;
2799        exit.kind = "worker.exited".into();
2800        exit.node_id = Some(nid.clone());
2801        exit.data = serde_json::json!({ "exit_code": 7 });
2802        apply_event(&paths, &exit).unwrap();
2803        assert!(read_node_opt(&paths, &nid)
2804            .unwrap()
2805            .unwrap()
2806            .worker_exit
2807            .is_some());
2808
2809        // Retry re-spawns the node — the stale exit fact must be gone.
2810        let mut retry = event(run_id);
2811        retry.seq = 4;
2812        retry.kind = "node.retry".into();
2813        retry.node_id = Some(nid.clone());
2814        retry.data = serde_json::json!({
2815            "attempt": 1,
2816            "reason": "agent-died",
2817            "branch": "wt/foo",
2818            "worktree_path": "/tmp/new-wt",
2819            "agent_pid": 222,
2820        });
2821        apply_event(&paths, &retry).unwrap();
2822
2823        let n = read_node_opt(&paths, &nid).unwrap().unwrap();
2824        assert!(
2825            n.worker_exit.is_none(),
2826            "node.retry must clear the previous attempt's worker_exit"
2827        );
2828        assert_eq!(
2829            n.status,
2830            Status::Pending,
2831            "retry returns the node to Pending"
2832        );
2833    }
2834}