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        KIND_MERGE_STARTED => reduce_merge_started(paths, ev),
301        KIND_MERGE_ABORTED => reduce_merge_aborted(paths, ev),
302        "child.spawned" => reduce_child_spawned(paths, ev),
303        "supervisor.attached" => reduce_supervisor_attached(paths, ev),
304        "supervisor.cursor_advanced" => reduce_supervisor_cursor_advanced(paths, ev),
305        "supervisor.exited" => Ok(vec![]),
306        // Append-only audit records from `/orchestrate` (decision log +
307        // pakkopysäytys). They mutate no projection — the event log is their
308        // canonical home — so they fold to a clean no-op. Listed explicitly
309        // (rather than relying on the `_` fallthrough) so the append path's
310        // transactional gate runs the same no-op plan for them and the intent
311        // is documented at the match site. They are NOT `node.report`, so the
312        // supervisor never mistakes them for a terminal signal.
313        "orchestrator.decision" | "discuss.critical" => Ok(vec![]),
314        // At-most-once marker the supervisor appends the first time a run is
315        // observed terminal, gating the `run create --notify` completion hook so
316        // a restart never re-fires it (issue `no-completion-notification-to-parent`).
317        // Mutates no projection — the event log is its only home — so it folds to
318        // a clean no-op. Listed explicitly so the append path's transactional gate
319        // runs the same no-op plan and the intent is documented here.
320        "run.notified" => Ok(vec![]),
321        // Best-effort teardown audit records from the supervisor's cleanup
322        // path. Each mutates no projection — the event log is their only home —
323        // so they fold to a clean no-op. Listed explicitly so the append path's
324        // transactional gate runs the same no-op plan and the intent is
325        // documented here.
326        //   - `cleanup.window_missing`: the node's tmux window could not be
327        //     located to close it (typically a manually-resolved rebase renamed
328        //     the window — issue `worktree-merge-orphans-tmux-window`).
329        //   - `cleanup.worktree_missing`: the worktree dir was already gone at
330        //     teardown (e.g. removed manually), so nothing to `worktree remove`.
331        //   - `cleanup.branch_remove_failed`: `git branch -{d,D}` refused (e.g.
332        //     unmerged commits, or the branch is already gone); the run completes
333        //     anyway (issue `supervisor-worktree-remove-no-force`).
334        //   - `cleanup.branch_preserved`: a BLOCKED terminal report
335        //     (`success: false`, no explicit merge) intentionally left the branch
336        //     and worktree in place for the human to pick up, instead of tearing
337        //     them down (issue `blocked-report-deletes-branch`).
338        //   - `cleanup.session_killed`: the run's managed `--headless` tmux
339        //     session was torn down once its last managed window was gone, so an
340        //     empty session is not left behind (issue
341        //     `headless-tmux-session-not-torn-down`).
342        //   - `cleanup.session_retained`: the same teardown was skipped because a
343        //     human had attached to the session — never yanked out from under
344        //     them.
345        "cleanup.window_missing"
346        | "cleanup.worktree_missing"
347        | "cleanup.branch_remove_failed"
348        | "cleanup.branch_preserved"
349        | "cleanup.session_killed"
350        | "cleanup.session_retained" => Ok(vec![]),
351        // Data-integrity audit record: the supervisor found a persisted child
352        // run id (in `supervisor.state.json`'s `spawned_children`) that fails
353        // `RunId` structural validation and quarantined it — a corrupt id that
354        // would otherwise resolve with `.ok()` and be silently skipped every
355        // tick, indistinguishable from a child that completed and was torn down
356        // (issue `wildly-glorious-food`). It mutates no projection — the event
357        // log is its only home — so it folds to a clean no-op. Listed
358        // explicitly so the append path's transactional gate runs the same
359        // no-op plan and the intent is documented here.
360        "supervisor.child_id_quarantined" => Ok(vec![]),
361        _ => Ok(vec![]),
362    }
363}
364
365/// The projection file [`commit_ops`] writes for `op`, keyed exactly as the
366/// `write_*` helpers key it internally. Shared by [`plan_projections`] (which
367/// reports the path) and conceptually by [`commit_ops`] (which writes it), so
368/// the enumerated path list can never name a different file than the one the
369/// reducer actually fsyncs.
370fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
371    match op {
372        ProjectionOp::Manifest(_) => paths.manifest(),
373        ProjectionOp::Node(n) => paths.node(&n.node_id),
374    }
375}
376
377/// Enumerate the projection files the reducer would write for `event`, in the
378/// order `commit_ops` would write them, *without* performing any write.
379///
380/// This is the single source of truth that ends the CLI/reducer divergence the
381/// `projected-paths-into-reducer` issue describes: rather than a hand-maintained
382/// list in `octl-cli` that drifts whenever a new projection is added, both the
383/// reducer and a caller's preflight (`event create --dry-run`) read the *same*
384/// `reduce_event_to_ops` plan. This function maps that plan to file paths;
385/// `apply_event` commits it. A new projection added to a reducer arm is
386/// therefore reflected here automatically.
387///
388/// Because it runs the real reducer plan against current projection state, the
389/// result is exact, not a guess: a state-dependent no-op (a settled node, an
390/// already-created projection, a terminal-guarded transition) yields an empty
391/// list — precisely the files `apply_event` would touch, which is none. A
392/// malformed-payload event surfaces the same [`Error::CorruptEventLog`] the
393/// real apply would, so a dry-run preflight cannot report success for an event
394/// the write path would reject.
395///
396/// Caller should hold the run's [`crate::lock::RunLock`] for a snapshot
397/// consistent with a concurrent reducer; a lock-free read is best-effort.
398pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
399    let ops = reduce_event_to_ops(paths, event)?;
400    Ok(ops.iter().map(|op| op_path(paths, op)).collect())
401}
402
403/// Apply one event to projections: plan via [`reduce_event_to_ops`], then
404/// [`commit_ops`]. No-op for unknown `kind`. Caller must hold the run's
405/// [`crate::lock::RunLock`].
406///
407/// Shares the one [`reduce_event_to_ops`] plan with [`plan_projections`]: the
408/// paths that function reports are exactly the files this one fsyncs, because
409/// both consume the same `ProjectionOp` vector (this commits it; that maps it to
410/// paths via [`op_path`]).
411///
412/// `pub(crate)`: applying an event in isolation (without the matching
413/// `events.jsonl` append) is an internal building block used by `cancel` (to
414/// re-fold a crash-stranded event) and a future `rebuild_projections_from_events`.
415/// External callers mutate state through
416/// [`crate::events::append_and_apply_event`] so the log and projections can
417/// never diverge.
418pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
419    let ops = reduce_event_to_ops(paths, ev)?;
420    commit_ops(paths, ops)
421}
422
423/// Validate an event WITHOUT writing anything — [`reduce_event_to_ops`] with
424/// the planned writes discarded. Returns `Err` in exactly the cases
425/// [`apply_event`] would (they share the one plan), so a dry-run check can
426/// never drift from the apply.
427///
428/// `#[cfg(test)]`: the append path validates by inspecting
429/// `reduce_event_to_ops` directly (it needs the planned ops anyway), so this
430/// discard-the-ops wrapper exists only for the reducer's agreement tests.
431#[cfg(test)]
432pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
433    reduce_event_to_ops(paths, ev).map(|_| ())
434}
435
436/// The envelope `node_id` that a `node.*` event must carry, with the same
437/// `CorruptEventLog` message `apply_*` produces. Shared by validate/apply so
438/// the missing-id check can't drift between them.
439fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
440    ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
441        path: events_path.to_path_buf(),
442        reason: format!(
443            "event seq={} kind={} missing top-level `node_id`",
444            ev.seq, ev.kind
445        ),
446    })
447}
448
449fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
450    // Idempotent: a replayed `run.created` against an existing manifest is a
451    // no-op (but validates that `run_id` matches; otherwise the event log
452    // is being applied to the wrong run).
453    if let Some(existing) = read_manifest_opt(paths)? {
454        if existing.run_id != ev.run_id {
455            return Err(Error::CorruptEventLog {
456                path: paths.manifest(),
457                reason: format!(
458                    "run.created run_id={} conflicts with existing manifest run_id={}",
459                    ev.run_id, existing.run_id
460                ),
461            });
462        }
463        return Ok(vec![]);
464    }
465    let events_path = paths.events();
466    let d = &ev.data;
467    let kind =
468        data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
469            path: events_path.clone(),
470            reason: "run.created missing/invalid `kind`".into(),
471        })?;
472    let lifecycle: Lifecycle = serde_json::from_value(
473        d.get("lifecycle").cloned().unwrap_or(Value::Null),
474    )
475    .map_err(|_| Error::CorruptEventLog {
476        path: events_path.clone(),
477        reason: "run.created missing/invalid `lifecycle`".into(),
478    })?;
479    let title = want_str(&events_path, ev, d, "title")?.to_string();
480    let m = Manifest {
481        schema_version: STATE_SCHEMA_VERSION,
482        // Created at the watermark floor; the append path advances it to this
483        // event's `seq` (after the manifest is fsynced) in `advance_applied_seq`.
484        applied_seq: 0,
485        // `run_id == paths.run_id` was verified at `reduce_event_to_ops` entry.
486        run_id: paths.run_id.clone(),
487        kind,
488        lifecycle,
489        title,
490        status: Status::Pending,
491        created_at: ev.ts,
492        updated_at: ev.ts,
493        source_repo: d
494            .get("source_repo")
495            .and_then(Value::as_str)
496            .map(str::to_string),
497        source_branch: d
498            .get("source_branch")
499            .and_then(Value::as_str)
500            .map(str::to_string),
501        worktree_root: d
502            .get("worktree_root")
503            .and_then(Value::as_str)
504            .map(str::to_string),
505        managed_tmux_session: d
506            .get("managed_tmux_session")
507            .and_then(Value::as_str)
508            .map(str::to_string),
509        notify_cmd: d
510            .get("notify_cmd")
511            .and_then(Value::as_str)
512            .map(str::to_string),
513        harness: d.get("harness").and_then(Value::as_str).map(str::to_string),
514        node_count: 0,
515        parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
516        parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
517    };
518    Ok(vec![ProjectionOp::Manifest(m)])
519}
520
521fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
522    let mut m = match read_manifest_opt(paths)? {
523        Some(m) => m,
524        None => return Ok(vec![]),
525    };
526    let new_status = require_status(ev, paths.events())?;
527    // Terminal-state guard: a settled run never transitions again (e.g. a
528    // late `run.status running` after a cancel). See run-cli-read/handoff.md D5.
529    if m.status.is_terminal() {
530        trace_terminal_noop(ev, m.status, new_status);
531        return Ok(vec![]);
532    }
533    if m.status == new_status {
534        return Ok(vec![]);
535    }
536    m.status = new_status;
537    m.updated_at = ev.ts;
538    Ok(vec![ProjectionOp::Manifest(m)])
539}
540
541/// Reconstruct the fully-qualified tmux identity from `node.created` event
542/// data. Returns `Some` only when both `tmux_session` and `tmux_window_id` are
543/// present and non-empty — the minimum needed to match a window. `tmux_socket`
544/// is optional (a default-socket spawn may emit null); an empty socket is
545/// normalized to `None` so the watchdog never invokes `tmux -S ""`.
546/// `tmux_pane_id` is likewise optional (create.sh predating it emits nothing);
547/// agent-log capture falls back to the window's active pane when absent. Legacy
548/// events from a create.sh that predates the qualified fields (or that emit a
549/// partial/empty identity) yield `None`, so the node falls back to bare-name
550/// matching on `tmux_window`.
551fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
552    let nonempty = |key| {
553        d.get(key)
554            .and_then(Value::as_str)
555            .map(str::trim)
556            .filter(|s| !s.is_empty())
557            .map(str::to_string)
558    };
559    let session = nonempty("tmux_session")?;
560    let window_id = nonempty("tmux_window_id")?;
561    Some(TmuxIdentity {
562        socket: nonempty("tmux_socket"),
563        session,
564        window_id,
565        // Optional: create.sh predating the field (or a failed pane query)
566        // emits no `tmux_pane_id`; capture then falls back to `window_id`.
567        pane_id: nonempty("tmux_pane_id"),
568    })
569}
570
571fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
572    let events_path = paths.events();
573    // The envelope `node_id` is already a validated `NodeId` (parsed on read),
574    // so take it directly — no re-parse needed.
575    let node_id = require_envelope_node_id(&events_path, ev)?;
576    // Idempotent on replay: skip if the node already exists.
577    if read_node_opt(paths, &node_id)?.is_some() {
578        return Ok(vec![]);
579    }
580    let d = &ev.data;
581    let kind =
582        data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
583            path: events_path.clone(),
584            reason: format!(
585                "event seq={} kind=node.created missing/invalid `kind`",
586                ev.seq
587            ),
588        })?;
589    let n = Node {
590        schema_version: STATE_SCHEMA_VERSION,
591        node_id,
592        // `run_id == paths.run_id` was verified at `reduce_event_to_ops` entry.
593        run_id: paths.run_id.clone(),
594        parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
595        kind,
596        status: Status::Pending,
597        task: d.get("task").and_then(Value::as_str).map(str::to_string),
598        worktree_path: d
599            .get("worktree_path")
600            .and_then(Value::as_str)
601            .map(str::to_string),
602        branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
603        base_sha: d
604            .get("base_sha")
605            .and_then(Value::as_str)
606            .filter(|s| !s.is_empty())
607            .map(str::to_string),
608        tmux_window: d
609            .get("tmux_window")
610            .and_then(Value::as_str)
611            .map(str::to_string),
612        tmux_identity: tmux_identity_from_data(d),
613        agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
614        agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
615        supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
616        children: Vec::new(),
617        started_at: Some(ev.ts),
618        updated_at: ev.ts,
619        last_report: None,
620        last_processed_report_seq_by_child: serde_json::Map::default(),
621        retry_attempts: 0,
622        worker_exit: None,
623        pending_merge: None,
624        first_death_at: None,
625    };
626    let mut ops = vec![ProjectionOp::Node(n)];
627    if let Some(mut m) = read_manifest_opt(paths)? {
628        // `node_count` is derived from the projection directories in
629        // `advance_applied_seq`, never incremented here — see the module note
630        // and issue `manifest-counter-desync`. This op only refreshes the run's
631        // last-activity timestamp.
632        m.updated_at = ev.ts;
633        ops.push(ProjectionOp::Manifest(m));
634    }
635    Ok(ops)
636}
637
638/// Rewire an existing node to a freshly re-spawned agent after an empty-handed
639/// `agent-died` bounded auto-retry (issue `autoretry-agent-died-worker`). The
640/// supervisor tore down the dead worker's stale worktree and `create.sh`'d a
641/// clean one at the run's source branch; this event carries the new spawn
642/// metadata (`branch`, `base_sha`, `worktree_path`, tmux identity, `agent_pid`)
643/// plus the audit fields (`attempt`, `reason`).
644///
645/// It updates the node in place: the new agent's coordinates replace the dead
646/// one's, `status` returns to `Pending`, `started_at` is re-stamped so the
647/// watchdog's spawn-grace window re-applies to the new agent, `last_report` is
648/// cleared, and `retry_attempts` is incremented — the DURABLE, restart-safe
649/// bound the watchdog checks before scheduling the next retry.
650///
651/// Guards, mirroring the other node reducers:
652/// - A missing node is a no-op (a retry event whose node was never created).
653/// - A TERMINAL node is never resurrected (a settled node is frozen): if a real
654///   `node.report` raced in and terminalized the node, the retry is a dead event.
655///   This keeps replay robust and preserves the terminal-state invariant.
656fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
657    let events_path = paths.events();
658    let node_id = require_envelope_node_id(&events_path, ev)?;
659    let mut n = match read_node_opt(paths, &node_id)? {
660        Some(n) => n,
661        None => return Ok(vec![]),
662    };
663    // Terminal-state guard: a settled node is frozen. A late `node.report` that
664    // beat this retry to the lock wins; the retry must not resurrect it.
665    if n.status.is_terminal() {
666        tracing::debug!(
667            target: "octl_core::reducer",
668            seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
669            "no-op: node.retry against terminal node"
670        );
671        return Ok(vec![]);
672    }
673    let d = &ev.data;
674    // Rewire to the new agent. Each field mirrors `reduce_node_created`'s parsing
675    // so the projection shape is identical to a fresh spawn.
676    n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
677    n.base_sha = d
678        .get("base_sha")
679        .and_then(Value::as_str)
680        .filter(|s| !s.is_empty())
681        .map(str::to_string);
682    n.worktree_path = d
683        .get("worktree_path")
684        .and_then(Value::as_str)
685        .map(str::to_string);
686    n.tmux_window = d
687        .get("tmux_window")
688        .and_then(Value::as_str)
689        .map(str::to_string);
690    n.tmux_identity = tmux_identity_from_data(d);
691    n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
692    n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
693    n.status = Status::Pending;
694    n.started_at = Some(ev.ts);
695    n.updated_at = ev.ts;
696    n.last_report = None;
697    // Drop any in-flight merge transaction from the PREVIOUS attempt: the retry
698    // rewires the node to a new branch/worktree/agent, so a `pending_merge` that
699    // referenced the dead attempt's branch must not carry forward — recovery would
700    // otherwise judge the new attempt from the old worker's merge state (issue
701    // `merge-transaction-recovery`, /llm-review finding).
702    n.pending_merge = None;
703    // Clear the previous attempt's told exit fact: the freshly re-spawned worker
704    // is a NEW process, so a stale `worker_exit` must not carry over — otherwise
705    // the supervisor's told-fact pass would instantly (mis)judge the new attempt
706    // from the dead one's exit (issue `thin-exit-status-launcher`).
707    n.worker_exit = None;
708    // Clear the previous attempt's first-death anchor: the residual crash backstop
709    // must measure the NEW attempt's own post-death grace from scratch, not inherit
710    // the dead attempt's timestamp (which would fire the backstop with no grace on
711    // the fresh worker's first confirmed death). Issue `typed-supervisor-outcomes`.
712    n.first_death_at = None;
713    // The event carries its ABSOLUTE attempt number (the supervisor set it to
714    // `retry_attempts + 1` at emit time). Assign it directly rather than a blind
715    // `+= 1`: this makes the projection a pure function of the event, so a
716    // full replay from seq 0, or a (guarded-against but defensive) double-apply,
717    // converges to the same `retry_attempts` the log declares — the audit count
718    // and the durable bound can never disagree. A legacy/malformed event with no
719    // parseable `attempt` falls back to the monotone increment.
720    n.retry_attempts = d
721        .get("attempt")
722        .and_then(Value::as_u64)
723        .map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
724    Ok(vec![ProjectionOp::Node(n)])
725}
726
727fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
728    let events_path = paths.events();
729    let node_id = require_envelope_node_id(&events_path, ev)?;
730    let mut n = match read_node_opt(paths, &node_id)? {
731        Some(n) => n,
732        None => return Ok(vec![]),
733    };
734    let new_status = require_status(ev, events_path)?;
735    // Terminal-state guard: a settled node never transitions again. See
736    // run-cli-read/handoff.md D5.
737    if n.status.is_terminal() {
738        trace_terminal_noop(ev, n.status, new_status);
739        return Ok(vec![]);
740    }
741    if n.status == new_status {
742        return Ok(vec![]);
743    }
744    n.status = new_status;
745    // A terminal `node.status` (e.g. a watchdog-synthesized failure) ends the
746    // node's lifecycle, so any in-flight merge transaction is moot — clear it so a
747    // `pending_merge` is not stranded on a terminal node (recovery skips terminal
748    // nodes, so an uncleared one would dangle forever). Issue
749    // `merge-transaction-recovery` (/llm-review finding).
750    if new_status.is_terminal() {
751        n.pending_merge = None;
752    }
753    n.updated_at = ev.ts;
754    Ok(vec![ProjectionOp::Node(n)])
755}
756
757fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
758    let events_path = paths.events();
759    let node_id = require_envelope_node_id(&events_path, ev)?;
760    let mut n = match read_node_opt(paths, &node_id)? {
761        Some(n) => n,
762        None => return Ok(vec![]),
763    };
764    // Terminal-state guard *before* payload validation: a node that already
765    // reached a terminal state is settled, so a late-arriving report (e.g. an
766    // agent success racing a `run cancel`) is a dead event — it must not
767    // resurrect the node, and must not even decorate the projection, so
768    // `last_report` is left untouched. Guarding first also keeps replay
769    // robust: a malformed dead report against a settled node is a clean
770    // no-op rather than a `CorruptEventLog` that would brick rebuild of a
771    // log `append_and_apply_event` already committed. See run-cli-read/handoff.md
772    // D5. (3/4 of /llm-review preferred guard-before-validate over the
773    // reverse the issue spec sketched; the required CorruptEventLog cases
774    // all target live nodes, so validation still runs for them.)
775    if n.status.is_terminal() {
776        // ONE exception to the dead-event rule: a late, CONFIRMED explicit-merge
777        // report is adopted even against a terminal node (issue
778        // `reducer-adopt-explicit-merge`). A watchdog `agent-died` false positive
779        // on a long-lived interactive run can terminalize a node BEFORE the user's
780        // `run merge` report arrives; an explicit user merge carries strictly
781        // higher-fidelity ground truth (the branch demonstrably landed in source)
782        // than a watchdog timeout, so it wins. Overwriting `last_report` here is
783        // what lets `any_node_merged_explicitly` see the merge and the SUPERVISOR
784        // — invariant #5's canonical teardown actor — warrant teardown, instead of
785        // the CLI compensating inline (issues `merge-skips-teardown`,
786        // `agent-died-merge-no-teardown-interactive`).
787        //
788        // Scoped tightly, on BOTH sides:
789        //   - incoming: a CONFIRMED SUCCESSFUL explicit merge
790        //     (`via == "explicit-merge"`, `success == true`, not `cancelled`) —
791        //     matches exactly the force-`-D` teardown gate (`node_branch_merged`),
792        //     so a failed/cancelled or non-merge late report never resurrects a
793        //     settled node and unmerged-work preservation is untouched.
794        //   - prior: only a `Failed` or `Done` node (positive whitelist). A
795        //     `Cancelled` terminal is a DELIBERATE `run cancel` teardown, not a
796        //     watchdog false positive, so a later merge does not override it (it
797        //     stays cancelled — matching the existing "late success report keeps the
798        //     cancel" reducer contract). The whitelist (rather than `!= Cancelled`)
799        //     is future-safe: a new deliberate-teardown terminal added later is not
800        //     silently resurrected to Done.
801        // Idempotent: if this exact report is already the node's `last_report`,
802        // re-folding it on replay is a clean no-op (never churns `updated_at`).
803        //
804        // NOTE — the RUN manifest is intentionally NOT reconciled here (it may stay
805        // `Failed` if a supervisor already rolled it up from the watchdog terminal).
806        // That is the pre-existing `false-failed-after-merge` symptom, NOT introduced
807        // by this change (the prior inline reclaim left the manifest `Failed` too):
808        // a run whose manifest was still non-terminal at adoption time DOES roll up
809        // to `Done` (the reattached supervisor's rollup sees the node `Done`); only
810        // an ALREADY-rolled-up terminal manifest stays put, because reconciling a
811        // settled run status is a distinct change to the run-status terminal guard,
812        // deliberately out of scope. Teardown fires either way (gated on
813        // `manifest.status.is_terminal()` + the merge marker), so no resource leaks.
814        if matches!(n.status, Status::Failed | Status::Done)
815            && report_is_confirmed_explicit_merge(&ev.data)
816        {
817            if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
818                return Ok(vec![]);
819            }
820            tracing::info!(
821                target: "octl_core::reducer",
822                seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
823                "adopting late explicit-merge report against terminal node (invariant #5 teardown)"
824            );
825            n.last_report = Some(ev.data.clone());
826            // A confirmed merge is a terminal SUCCESS: the work landed in source.
827            // (A false watchdog `Failed` is corrected to `Done`; a genuine `Done`
828            // stays `Done` with the merge marker adopted so teardown is warranted.)
829            n.status = Status::Done;
830            // The merge completed, so any in-flight merge transaction is resolved:
831            // clear it so recovery does not later re-examine a settled node
832            // (issue `merge-transaction-recovery`).
833            n.pending_merge = None;
834            n.updated_at = ev.ts;
835            return Ok(vec![ProjectionOp::Node(n)]);
836        }
837        tracing::debug!(
838            target: "octl_core::reducer",
839            seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
840            "no-op: node.report against terminal node"
841        );
842        return Ok(vec![]);
843    }
844    // Live node: validate the report's terminal outcome. A `node.report`
845    // must express exactly one terminal outcome — success/failure XOR
846    // cancellation. Anything else (a bare `{}` with neither, or the
847    // contradiction `success: true` + `cancelled: true`) is a corrupt event:
848    // the reducer is the canonical gate, so reject it rather than silently
849    // leaving the node in a dangling state. See design.md §7.7 and
850    // node-cli-read/handoff.md D4.
851    let new_status = report_terminal_status(&events_path, ev)?;
852    n.last_report = Some(ev.data.clone());
853    n.status = new_status;
854    // Any terminal outcome resolves an in-flight merge transaction: a successful
855    // `explicit-merge` report completes it here (the normal, no-crash path), and
856    // any other terminal report ends the node's lifecycle so no merge recovery
857    // should later fire (issue `merge-transaction-recovery`).
858    n.pending_merge = None;
859    n.updated_at = ev.ts;
860    Ok(vec![ProjectionOp::Node(n)])
861}
862
863/// Fold a `worker.exited` event onto the node's `worker_exit` field (design.md
864/// §2.1 / A1). This records the launcher shim's **told** exit status as a
865/// durable fact; it deliberately does NOT transition `status`. Terminalization
866/// is the supervisor's decision via the typed outcome table (§2.6) — a non-zero
867/// or signalled exit becomes `failed`, while a clean exit without a merge stays
868/// non-terminal (attention-required). Keeping the status transition out of the
869/// reducer is what lets the clean-but-unmerged worker remain a visible, resumable
870/// state instead of an auto-failed one.
871///
872/// Payload contract: at least one of `exit_code` (JSON integer) or `signal`
873/// (JSON integer) must be present; a payload carrying neither is a corrupt event
874/// (the reducer is the canonical gate). The fold is idempotent and **first-write-
875/// wins**: once `worker_exit` is set, a replay or a spurious duplicate is a clean
876/// no-op, so a full replay from seq 0 converges to the same recorded fact.
877fn reduce_worker_exited(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
878    let events_path = paths.events();
879    let node_id = require_envelope_node_id(&events_path, ev)?;
880    let code = optional_i32(&ev.data, "exit_code", &events_path, ev)?;
881    let signal = optional_i32(&ev.data, "signal", &events_path, ev)?;
882    // A worker exit is EXACTLY one of a normal return (code) or a signal death
883    // (signal). Neither is meaningless; both is contradictory (a process cannot
884    // both return a code and be killed) — reject either rather than record an
885    // ambiguous fact the outcome classifier would then have to disambiguate.
886    match (code, signal) {
887        (Some(_), None) | (None, Some(_)) => {}
888        _ => {
889            return Err(Error::CorruptEventLog {
890                path: events_path,
891                reason: format!(
892                    "event seq={} kind=worker.exited must carry EXACTLY one of `exit_code` or `signal`",
893                    ev.seq
894                ),
895            });
896        }
897    }
898    let mut n = match read_node_opt(paths, &node_id)? {
899        Some(n) => n,
900        // No projection to decorate. A `worker.exited` for a node that does not
901        // exist folds to nothing — consistent with the other node reducers
902        // (`node.report` / `node.status`). In practice the shim validates the node
903        // exists before it can record an exit, and normal append ordering always
904        // places `node.created` first, so this is only hit for a genuinely orphan
905        // event.
906        None => return Ok(vec![]),
907    };
908    // First-write-wins: the shim fires exactly once per worker, so an existing
909    // record is a replay/duplicate. Leaving it untouched keeps the fold a pure
910    // function of the first exit event and never churns `updated_at`.
911    if n.worker_exit.is_some() {
912        return Ok(vec![]);
913    }
914    n.worker_exit = Some(WorkerExit {
915        code,
916        signal,
917        at: ev.ts,
918    });
919    n.updated_at = ev.ts;
920    Ok(vec![ProjectionOp::Node(n)])
921}
922
923/// Fold a `node.death_observed` event onto [`Node::first_death_at`], recording
924/// the FIRST tick on which the supervisor saw this node's worker confirmed-dead
925/// with no told `worker.exited` and no merge — the durable anchor for the
926/// residual crash backstop's fixed post-death grace (design.md §2.1a, issue
927/// `typed-supervisor-outcomes`).
928///
929/// The anchor is the event's own timestamp (`ev.ts`) — no payload field needed.
930/// The fold is **first-write-wins**: the anchor is monotonic, so a later
931/// re-observation (a supervisor restart still seeing the dead pid) never resets
932/// the clock, and a full replay from seq 0 converges to the first observation. A
933/// `node.death_observed` for a missing or already terminal node folds to nothing
934/// (the backstop is moot once the node settles).
935fn reduce_node_death_observed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
936    let events_path = paths.events();
937    let node_id = require_envelope_node_id(&events_path, ev)?;
938    let mut n = match read_node_opt(paths, &node_id)? {
939        Some(n) => n,
940        None => return Ok(vec![]),
941    };
942    // First-write-wins (monotonic anchor). No-op once the backstop is moot or a
943    // higher-fidelity fact exists — a terminal node, a told `worker.exited`, a
944    // landed report, or an in-flight merge transaction. The supervisor's emitter
945    // already gates on all of these under the exclusive lock; mirroring them here
946    // keeps a from-scratch replay convergent regardless of caller.
947    if n.first_death_at.is_some()
948        || n.status.is_terminal()
949        || n.worker_exit.is_some()
950        || n.last_report.is_some()
951        || n.pending_merge.is_some()
952    {
953        return Ok(vec![]);
954    }
955    n.first_death_at = Some(ev.ts);
956    n.updated_at = ev.ts;
957    Ok(vec![ProjectionOp::Node(n)])
958}
959
960/// The event kind `run merge` appends BEFORE mutating git to record the
961/// in-flight merge transaction (design.md §2.1b / A2). Its `data` payload is a
962/// serialized [`MergeTxn`]; the reducer folds it onto [`Node::pending_merge`].
963pub const KIND_MERGE_STARTED: &str = "merge.started";
964
965/// The event kind recovery appends when it resolves a pending merge transaction
966/// by REJECTING it — the recorded source ref never moved (the git mutation never
967/// landed), so the worker's branch + work are preserved and the transaction is
968/// cleared. Its `data` carries `op_id` (which transaction) and `reason`.
969pub const KIND_MERGE_ABORTED: &str = "merge.aborted";
970
971/// Fold a `merge.started` event onto [`Node::pending_merge`], recording the
972/// in-flight `run merge` transaction BEFORE the git mutation so a crash between
973/// the git merge and the terminal `explicit-merge` report can be resolved
974/// deterministically by OID (design.md §2.1b / A2, issue
975/// `merge-transaction-recovery`). The reducer deliberately does NOT transition
976/// `status`: recording a transaction is not a terminal outcome.
977///
978/// Payload contract: the `data` is a serialized [`MergeTxn`]; a payload missing
979/// required fields is a corrupt event (the reducer is the canonical gate, so the
980/// append is rejected before any byte is written). The fold is idempotent —
981/// re-folding the same `op_id` on replay is a clean no-op — and last-write-wins
982/// across a fresh attempt's larger `op_id` (each `run merge` re-reads
983/// `expected_source_oid`, so the newest record is authoritative).
984fn reduce_merge_started(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
985    let events_path = paths.events();
986    let node_id = require_envelope_node_id(&events_path, ev)?;
987    let txn: MergeTxn =
988        serde_json::from_value(ev.data.clone()).map_err(|e| Error::CorruptEventLog {
989            path: events_path.clone(),
990            reason: format!(
991                "event seq={} kind=merge.started has an invalid MergeTxn payload: {e}",
992                ev.seq
993            ),
994        })?;
995    let mut n = match read_node_opt(paths, &node_id)? {
996        Some(n) => n,
997        None => return Ok(vec![]),
998    };
999    // A terminal node has no in-flight merge to track — `run merge` is refused on
1000    // a terminal run at the CLI, so this is a dead/duplicate event. Ignore it
1001    // (never resurrect the projection).
1002    if n.status.is_terminal() {
1003        return Ok(vec![]);
1004    }
1005    // Idempotent: re-folding the SAME transaction on replay must not churn
1006    // `updated_at`.
1007    if n.pending_merge.as_ref().map(|t| t.op_id.as_str()) == Some(txn.op_id.as_str()) {
1008        return Ok(vec![]);
1009    }
1010    n.pending_merge = Some(Box::new(txn));
1011    n.updated_at = ev.ts;
1012    Ok(vec![ProjectionOp::Node(n)])
1013}
1014
1015/// Fold a `merge.aborted` event, clearing [`Node::pending_merge`] iff it names
1016/// the transaction being aborted (`op_id` match). Recovery appends this when it
1017/// determines a pending merge's git mutation never landed (the source ref is
1018/// still at `expected_source_oid`) or moved unexpectedly — the transaction is
1019/// rejected, the worker's branch + work are preserved, and the node stays
1020/// whatever non-terminal status it was (a retry may re-attempt the merge).
1021///
1022/// The `op_id` guard is what keeps this from clobbering a *newer* transaction: a
1023/// stale `merge.aborted` for a superseded attempt (a different `op_id`) is a
1024/// clean no-op. Deliberately
1025/// does NOT transition `status`.
1026fn reduce_merge_aborted(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1027    let events_path = paths.events();
1028    let node_id = require_envelope_node_id(&events_path, ev)?;
1029    let op_id = ev
1030        .data
1031        .get("op_id")
1032        .and_then(Value::as_str)
1033        .ok_or_else(|| Error::CorruptEventLog {
1034            path: events_path.clone(),
1035            reason: format!(
1036                "event seq={} kind=merge.aborted is missing string `op_id`",
1037                ev.seq
1038            ),
1039        })?;
1040    let mut n = match read_node_opt(paths, &node_id)? {
1041        Some(n) => n,
1042        None => return Ok(vec![]),
1043    };
1044    // Clear only the transaction this event names. A mismatch (already resolved,
1045    // or a newer attempt is pending) is a clean no-op.
1046    match n.pending_merge.as_ref() {
1047        Some(t) if t.op_id == op_id => {}
1048        _ => return Ok(vec![]),
1049    }
1050    n.pending_merge = None;
1051    n.updated_at = ev.ts;
1052    Ok(vec![ProjectionOp::Node(n)])
1053}
1054
1055/// Emit an observability trace for a status event dropped by the terminal
1056/// guard. Re-applying the *same* terminal status is routine idempotent replay
1057/// (`debug`); an event carrying a *different* status is a real conflict that
1058/// should not occur on a well-formed log (`warn`) — e.g. a `done` node being
1059/// told to go `cancelled`. The guard no-ops either way; the level is the only
1060/// difference, so a genuine corruption signal is visible without flooding
1061/// logs on every replay.
1062fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
1063    if current == incoming {
1064        tracing::debug!(
1065            target: "octl_core::reducer",
1066            seq = ev.seq, kind = %ev.kind, status = ?current,
1067            "no-op: status re-applied to terminal target"
1068        );
1069    } else {
1070        tracing::warn!(
1071            target: "octl_core::reducer",
1072            seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
1073            "no-op: ignored conflicting transition from terminal target"
1074        );
1075    }
1076}
1077
1078/// True when a `node.report` payload is a CONFIRMED, SUCCESSFUL explicit merge —
1079/// the sole payload shape the terminal-node guard in [`reduce_node_report`]
1080/// adopts. Delegates to [`ReportOrigin::report_is_confirmed_merge`] so the
1081/// reducer's adoption gate reads the SAME merge truth as the supervisor's
1082/// teardown gate, the `landed` fallback, and `run wait`'s `merged` flag.
1083///
1084/// That truth prefers the typed [`ReportOrigin::RunMerge`] (issue
1085/// `retire-via-string`): the legacy `via: "explicit-merge"` string is honored
1086/// only as a fallback for a legacy report carrying NO `origin` field, so an
1087/// agent-authored report (normalized to an [`ReportOrigin::Agent`] origin by
1088/// `node report`) can never be adopted against a settled node on a forged `via`
1089/// string alone. It still requires `success == true` with `cancelled`
1090/// absent/`false` and strict boolean typing (a malformed payload a live node
1091/// would reject as `CorruptEventLog` cannot sneak an adoption in through this
1092/// terminal-only exception), and returns `false` rather than erroring so a
1093/// replay of such a dead event stays a clean no-op.
1094fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
1095    ReportOrigin::report_is_confirmed_merge(data)
1096}
1097
1098/// Derive the terminal status a `node.report` event asserts, enforcing the
1099/// success-XOR-cancelled invariant with strict boolean typing.
1100///
1101/// `cancelled: true` (with `success: false` or absent) → [`Status::Cancelled`].
1102/// Otherwise `success` must be present: `true` → [`Status::Done`], `false` →
1103/// [`Status::Failed`]. Neither field (bare `{}`), the contradiction
1104/// `success: true` + `cancelled: true`, or a non-boolean `success` /
1105/// `cancelled` is a [`Error::CorruptEventLog`].
1106fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
1107    let corrupt = |reason: String| Error::CorruptEventLog {
1108        path: events_path.to_path_buf(),
1109        reason,
1110    };
1111    let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
1112    let success = optional_bool(events_path, ev, &ev.data, "success")?;
1113    if cancelled {
1114        if success == Some(true) {
1115            return Err(corrupt(format!(
1116                "event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
1117                ev.seq
1118            )));
1119        }
1120        Ok(Status::Cancelled)
1121    } else {
1122        match success {
1123            Some(true) => Ok(Status::Done),
1124            Some(false) => Ok(Status::Failed),
1125            None => Err(corrupt(format!(
1126                "event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
1127                ev.seq
1128            ))),
1129        }
1130    }
1131}
1132
1133fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1134    // `child.spawned` is written to the PARENT run's events; the parent
1135    // spawning node is `ev.node_id`, the child run/node lives in `data`.
1136    let events_path = paths.events();
1137    let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
1138        path: events_path.clone(),
1139        reason: format!(
1140            "event seq={} kind=child.spawned missing parent `node_id`",
1141            ev.seq
1142        ),
1143    })?;
1144    let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
1145        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1146    let child_node_id = NodeId::parse_str(
1147        ev.data
1148            .get("child_node_id")
1149            .and_then(Value::as_str)
1150            .unwrap_or("n-0001"),
1151    )
1152    .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1153    let mut n = match read_node_opt(paths, &parent_node_id)? {
1154        Some(n) => n,
1155        None => return Ok(vec![]),
1156    };
1157    let new_ref = ChildRef {
1158        run_id: child_run_id,
1159        node_id: child_node_id,
1160    };
1161    if n.children.iter().any(|c| c == &new_ref) {
1162        // Already recorded — pure no-op so replayed events don't churn
1163        // `updated_at` or the projection file.
1164        return Ok(vec![]);
1165    }
1166    n.children.push(new_ref);
1167    n.updated_at = ev.ts;
1168    Ok(vec![ProjectionOp::Node(n)])
1169}
1170
1171/// `supervisor.attached` records the supervisor PID watching the envelope
1172/// node onto `Node.supervisor_pid`. Event-sourced replacement for the
1173/// supervisor's former direct `write_node` (issue
1174/// `supervisor-state-not-event-sourced`), so a from-scratch projection
1175/// rebuild reproduces the field.
1176///
1177/// Latest-wins: a later attach (a supervisor restart binds a fresh PID)
1178/// overrides the recorded value. Re-applying an event that carries the
1179/// already-recorded PID is a pure no-op, so replay never churns the
1180/// projection file's `updated_at`.
1181fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1182    let events_path = paths.events();
1183    let node_id = require_envelope_node_id(&events_path, ev)?;
1184    let raw = ev
1185        .data
1186        .get("pid")
1187        .and_then(Value::as_i64)
1188        .ok_or_else(|| Error::CorruptEventLog {
1189            path: events_path.clone(),
1190            reason: format!(
1191                "event seq={} kind=supervisor.attached missing/invalid `pid`",
1192                ev.seq
1193            ),
1194        })?;
1195    let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
1196        path: events_path.clone(),
1197        reason: format!(
1198            "event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
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    if n.supervisor_pid == Some(pid) {
1207        return Ok(vec![]);
1208    }
1209    n.supervisor_pid = Some(pid);
1210    n.updated_at = ev.ts;
1211    Ok(vec![ProjectionOp::Node(n)])
1212}
1213
1214/// `supervisor.cursor_advanced` mirrors the supervisor's per-child report
1215/// cursor onto the envelope (parent) node's `last_processed_report_seq_by_child`
1216/// map. Event-sourced replacement for the supervisor's former direct
1217/// `write_node` of that map (issue `supervisor-state-not-event-sourced`).
1218///
1219/// The cursor is monotonic: a `report_seq` at or below the recorded
1220/// high-water mark for this child is a no-op, so replaying the same event —
1221/// or an older out-of-order one — never moves the cursor backward or churns
1222/// the projection. This is the §7.3 idempotency guarantee at the reducer
1223/// boundary.
1224fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1225    let events_path = paths.events();
1226    let node_id = require_envelope_node_id(&events_path, ev)?;
1227    let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
1228    // Validate the child id even though it only becomes a map key — a forged
1229    // event must not smuggle a path-shaped or malformed run id into the
1230    // projection.
1231    RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
1232    let report_seq = ev
1233        .data
1234        .get("report_seq")
1235        .and_then(Value::as_u64)
1236        .ok_or_else(|| Error::CorruptEventLog {
1237            path: events_path.clone(),
1238            reason: format!(
1239                "event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
1240                ev.seq
1241            ),
1242        })?;
1243    let mut n = match read_node_opt(paths, &node_id)? {
1244        Some(n) => n,
1245        None => return Ok(vec![]),
1246    };
1247    if let Some(prev) = n
1248        .last_processed_report_seq_by_child
1249        .get(child_run_id)
1250        .and_then(Value::as_u64)
1251    {
1252        if report_seq <= prev {
1253            return Ok(vec![]);
1254        }
1255    }
1256    n.last_processed_report_seq_by_child
1257        .insert(child_run_id.to_string(), Value::from(report_seq));
1258    n.updated_at = ev.ts;
1259    Ok(vec![ProjectionOp::Node(n)])
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264    use super::*;
1265    use crate::schema::Event;
1266    use chrono::Utc;
1267    use tempfile::TempDir;
1268
1269    fn event(run_id: &str) -> Event {
1270        Event {
1271            ts: Utc::now(),
1272            seq: 1,
1273            kind: "run.status".into(),
1274            run_id: RunId::parse_str(run_id).unwrap(),
1275            node_id: None,
1276            idempotency_key: None,
1277            data: serde_json::json!({ "status": "running" }),
1278        }
1279    }
1280
1281    #[test]
1282    fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
1283        // The /orchestrate audit kinds are append-only: the reducer must plan
1284        // ZERO projection ops for them regardless of payload, so the event log
1285        // is their sole home and no projection is created or mutated.
1286        let tmp = TempDir::new().unwrap();
1287        let run_id = "01jxsnap000000000000000000";
1288        let rid = RunId::parse_str(run_id).unwrap();
1289        let dir = crate::run_dir(tmp.path(), &rid);
1290        std::fs::create_dir_all(&dir).unwrap();
1291        let paths = RunPaths::new(dir, run_id).unwrap();
1292
1293        // Bootstrap a manifest so we can prove the audit events leave it
1294        // byte-for-byte untouched (no counter churn, no status drift).
1295        let mut created = event(run_id);
1296        created.kind = "run.created".into();
1297        created.data = serde_json::json!({
1298            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1299        });
1300        apply_event(&paths, &created).expect("run.created applies");
1301        let manifest_before = std::fs::read(paths.manifest()).unwrap();
1302
1303        for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
1304            let mut ev = event(run_id);
1305            ev.seq = seq;
1306            ev.kind = kind.into();
1307            // A non-trivial payload to prove the reducer ignores it wholesale.
1308            ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
1309            let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
1310            assert!(ops.is_empty(), "{kind} must plan no projection ops");
1311            // apply_event is the plan+commit path; it must also be a clean no-op.
1312            apply_event(&paths, &ev).expect("audit kind applies as no-op");
1313        }
1314
1315        // The manifest is unchanged and no stray projection dirs appeared.
1316        assert_eq!(
1317            std::fs::read(paths.manifest()).unwrap(),
1318            manifest_before,
1319            "audit events must not mutate the manifest"
1320        );
1321        assert!(!paths.nodes_dir().exists(), "no node projection created");
1322    }
1323
1324    #[test]
1325    fn run_created_folds_harness_when_present_and_defaults_none() {
1326        let tmp = TempDir::new().unwrap();
1327
1328        // A `run.created` carrying `harness` folds it onto the manifest.
1329        let run_id = "01jxhrnsaa0000000000000001";
1330        let rid = RunId::parse_str(run_id).unwrap();
1331        let dir = crate::run_dir(tmp.path(), &rid);
1332        std::fs::create_dir_all(&dir).unwrap();
1333        let paths = RunPaths::new(dir, run_id).unwrap();
1334        let mut created = event(run_id);
1335        created.kind = "run.created".into();
1336        created.data = serde_json::json!({
1337            "kind": "spinoff", "lifecycle": "autonomous", "title": "t",
1338            "harness": "pi", "harness_source": "flag",
1339        });
1340        apply_event(&paths, &created).expect("run.created applies");
1341        let m = read_manifest_opt(&paths).unwrap().unwrap();
1342        assert_eq!(m.harness.as_deref(), Some("pi"));
1343
1344        // A `run.created` WITHOUT `harness` (legacy / claude) leaves it `None`.
1345        let run_id2 = "01jxhrnsaa0000000000000002";
1346        let rid2 = RunId::parse_str(run_id2).unwrap();
1347        let dir2 = crate::run_dir(tmp.path(), &rid2);
1348        std::fs::create_dir_all(&dir2).unwrap();
1349        let paths2 = RunPaths::new(dir2, run_id2).unwrap();
1350        let mut created2 = event(run_id2);
1351        created2.kind = "run.created".into();
1352        created2.data =
1353            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1354        apply_event(&paths2, &created2).expect("run.created applies");
1355        let m2 = read_manifest_opt(&paths2).unwrap().unwrap();
1356        assert_eq!(m2.harness, None);
1357    }
1358
1359    /// Bootstrap a run manifest + one live `n-0001` spinoff node, returning its
1360    /// paths. Used by the `node.retry` reducer tests.
1361    fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1362        let rid = RunId::parse_str(run_id).unwrap();
1363        let dir = crate::run_dir(tmp.path(), &rid);
1364        std::fs::create_dir_all(&dir).unwrap();
1365        let paths = RunPaths::new(dir, run_id).unwrap();
1366        let mut created = event(run_id);
1367        created.kind = "run.created".into();
1368        created.data =
1369            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1370        apply_event(&paths, &created).expect("run.created applies");
1371        let mut node = event(run_id);
1372        node.seq = 2;
1373        node.kind = "node.created".into();
1374        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1375        node.data = serde_json::json!({
1376            "kind": "spinoff",
1377            "branch": "wt/foo",
1378            "worktree_path": "/tmp/old-wt",
1379            "agent_pid": 111,
1380        });
1381        apply_event(&paths, &node).expect("node.created applies");
1382        paths
1383    }
1384
1385    /// `node.retry` rewires the node to the freshly re-spawned agent, returns it to
1386    /// `Pending`, re-stamps `started_at`, and increments the durable
1387    /// `retry_attempts` bound (issue `autoretry-agent-died-worker`).
1388    #[test]
1389    fn node_retry_rewires_node_and_increments_attempts() {
1390        let tmp = TempDir::new().unwrap();
1391        let run_id = "01jxsnap000000000000000000";
1392        let paths = bootstrap_retry_node(&tmp, run_id);
1393
1394        let mut retry = event(run_id);
1395        retry.seq = 3;
1396        retry.kind = "node.retry".into();
1397        retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1398        retry.data = serde_json::json!({
1399            "attempt": 1,
1400            "reason": "agent-died",
1401            "branch": "wt/foo-r1",
1402            "base_sha": "a".repeat(40),
1403            "worktree_path": "/tmp/new-wt",
1404            "agent_pid": 222,
1405            "tmux_session": "s",
1406            "tmux_window_id": "@9",
1407        });
1408        apply_event(&paths, &retry).expect("node.retry applies");
1409
1410        let n = read_n0001(&paths);
1411        assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
1412        assert_eq!(
1413            n.branch.as_deref(),
1414            Some("wt/foo-r1"),
1415            "rewired to new branch"
1416        );
1417        assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
1418        assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
1419        assert_eq!(n.status, Status::Pending, "node returns to pending");
1420        assert!(n.last_report.is_none());
1421        assert_eq!(
1422            n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
1423            Some("@9"),
1424            "rewired tmux identity"
1425        );
1426
1427        // A second retry increments again — the bound is monotone.
1428        let mut retry2 = event(run_id);
1429        retry2.seq = 4;
1430        retry2.kind = "node.retry".into();
1431        retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1432        retry2.data = serde_json::json!({
1433            "attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
1434            "worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
1435        });
1436        apply_event(&paths, &retry2).expect("node.retry applies");
1437        assert_eq!(read_n0001(&paths).retry_attempts, 2);
1438    }
1439
1440    /// A `node.retry` against an already-terminal node is a dead event: the
1441    /// terminal-state invariant holds, so a late retry never resurrects a settled
1442    /// node (a real report that raced in wins).
1443    #[test]
1444    fn node_retry_against_terminal_node_is_noop() {
1445        let tmp = TempDir::new().unwrap();
1446        let run_id = "01jxsnap000000000000000000";
1447        let paths = bootstrap_retry_node(&tmp, run_id);
1448
1449        // Terminalize the node via a success report.
1450        let mut report = event(run_id);
1451        report.seq = 3;
1452        report.kind = "node.report".into();
1453        report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1454        report.data = serde_json::json!({ "success": true });
1455        apply_event(&paths, &report).expect("node.report applies");
1456        assert_eq!(read_n0001(&paths).status, Status::Done);
1457
1458        let mut retry = event(run_id);
1459        retry.seq = 4;
1460        retry.kind = "node.retry".into();
1461        retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1462        retry.data = serde_json::json!({
1463            "attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
1464            "worktree_path": "/tmp/new-wt", "agent_pid": 222,
1465        });
1466        apply_event(&paths, &retry).expect("node.retry applies as no-op");
1467
1468        let n = read_n0001(&paths);
1469        assert_eq!(n.status, Status::Done, "terminal node not resurrected");
1470        assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
1471        assert_eq!(n.agent_pid, Some(111), "not rewired");
1472    }
1473
1474    #[test]
1475    fn apply_event_rejects_event_from_a_different_run() {
1476        let tmp = TempDir::new().unwrap();
1477        let run_id = "01jxsnap000000000000000000";
1478        let rid = RunId::parse_str(run_id).unwrap();
1479        let dir = crate::run_dir(tmp.path(), &rid);
1480        std::fs::create_dir_all(&dir).unwrap();
1481        let paths = RunPaths::new(dir, run_id).unwrap();
1482
1483        // An event whose envelope names a different run must not be folded.
1484        let foreign = event("02jxsnap000000000000000000");
1485        let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
1486        assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
1487
1488        // The matching run_id is accepted (no projection exists yet, so
1489        // `run.status` is a clean no-op rather than an error).
1490        let mine = event(run_id);
1491        apply_event(&paths, &mine).expect("matching run_id must be accepted");
1492    }
1493
1494    #[test]
1495    fn tmux_identity_from_data_reads_qualified_fields() {
1496        let d = serde_json::json!({
1497            "tmux_socket": "/private/tmp/tmux-501/default",
1498            "tmux_session": "octl",
1499            "tmux_window_id": "@42",
1500        });
1501        let id = tmux_identity_from_data(&d).expect("qualified identity");
1502        assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
1503        assert_eq!(id.session, "octl");
1504        assert_eq!(id.window_id, "@42");
1505        // No pane_id in this event → None (back-compat / older create.sh).
1506        assert_eq!(id.pane_id, None);
1507
1508        // Null socket is tolerated — session + window_id are the minimum.
1509        let d2 = serde_json::json!({
1510            "tmux_socket": null,
1511            "tmux_session": "octl",
1512            "tmux_window_id": "@7",
1513        });
1514        let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
1515        assert_eq!(id2.socket, None);
1516        assert_eq!(id2.window_id, "@7");
1517
1518        // A create.sh that emits `tmux_pane_id` is folded into the identity.
1519        let d3 = serde_json::json!({
1520            "tmux_session": "octl",
1521            "tmux_window_id": "@42",
1522            "tmux_pane_id": "%7",
1523        });
1524        let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
1525        assert_eq!(id3.pane_id.as_deref(), Some("%7"));
1526        assert_eq!(id3.capture_target(), "%7");
1527
1528        // Explicit `tmux_pane_id: null` (create.sh emits null when its pane
1529        // query failed) must fold to None — never `Some("null")`.
1530        let d4 = serde_json::json!({
1531            "tmux_session": "octl",
1532            "tmux_window_id": "@42",
1533            "tmux_pane_id": null,
1534        });
1535        let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
1536        assert_eq!(id4.pane_id, None);
1537        assert_eq!(id4.capture_target(), "@42");
1538    }
1539
1540    #[test]
1541    fn tmux_identity_from_data_back_compat_is_none() {
1542        // Legacy create.sh: no qualified fields at all.
1543        let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
1544        assert!(tmux_identity_from_data(&legacy).is_none());
1545        // Partial (window_id without session) is also insufficient → None.
1546        let partial = serde_json::json!({ "tmux_window_id": "@42" });
1547        assert!(tmux_identity_from_data(&partial).is_none());
1548    }
1549
1550    /// End-to-end: a `node.created` event carrying the qualified fields folds
1551    /// them into `Node.tmux_identity`; one without them leaves it `None`.
1552    #[test]
1553    fn node_created_populates_tmux_identity() {
1554        let tmp = TempDir::new().unwrap();
1555        let run_id = "01jxsnap000000000000000000";
1556        let rid = RunId::parse_str(run_id).unwrap();
1557        let dir = crate::run_dir(tmp.path(), &rid);
1558        std::fs::create_dir_all(&dir).unwrap();
1559        let paths = RunPaths::new(dir, run_id).unwrap();
1560
1561        let mut ev = event(run_id);
1562        ev.seq = 2;
1563        ev.kind = "node.created".into();
1564        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1565        ev.data = serde_json::json!({
1566            "kind": "spinoff",
1567            "tmux_window": "🚀 wt/x",
1568            "tmux_socket": "/private/tmp/tmux-501/default",
1569            "tmux_session": "octl",
1570            "tmux_window_id": "@42",
1571        });
1572        apply_event(&paths, &ev).expect("node.created applies");
1573        let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
1574            .unwrap()
1575            .unwrap();
1576        let id = n.tmux_identity.expect("qualified identity recorded");
1577        assert_eq!(id.session, "octl");
1578        assert_eq!(id.window_id, "@42");
1579        assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
1580
1581        // A second run with a legacy event leaves tmux_identity None.
1582        let run2 = "02jxsnap000000000000000000";
1583        let rid2 = RunId::parse_str(run2).unwrap();
1584        let dir2 = crate::run_dir(tmp.path(), &rid2);
1585        std::fs::create_dir_all(&dir2).unwrap();
1586        let paths2 = RunPaths::new(dir2, run2).unwrap();
1587        let mut ev2 = event(run2);
1588        ev2.seq = 2;
1589        ev2.kind = "node.created".into();
1590        ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1591        ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
1592        apply_event(&paths2, &ev2).expect("legacy node.created applies");
1593        let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
1594            .unwrap()
1595            .unwrap();
1596        assert!(n2.tmux_identity.is_none());
1597        assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
1598    }
1599
1600    /// Bootstrap a run with a single `n-0001` node via the event-sourced path,
1601    /// returning its paths. Shared by the supervisor-state replay tests below.
1602    fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1603        let rid = RunId::parse_str(run_id).unwrap();
1604        let dir = crate::run_dir(tmp.path(), &rid);
1605        std::fs::create_dir_all(&dir).unwrap();
1606        let paths = RunPaths::new(dir, run_id).unwrap();
1607
1608        let mut created = event(run_id);
1609        created.kind = "run.created".into();
1610        created.data = serde_json::json!({
1611            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1612        });
1613        apply_event(&paths, &created).expect("run.created applies");
1614
1615        let mut node = event(run_id);
1616        node.seq = 2;
1617        node.kind = "node.created".into();
1618        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1619        node.data = serde_json::json!({ "kind": "spinoff" });
1620        apply_event(&paths, &node).expect("node.created applies");
1621        paths
1622    }
1623
1624    fn read_n0001(paths: &RunPaths) -> Node {
1625        read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
1626            .unwrap()
1627            .unwrap()
1628    }
1629
1630    fn merge_started_event(run_id: &str, seq: u64, op_id: &str, expected: &str) -> Event {
1631        let mut ev = event(run_id);
1632        ev.seq = seq;
1633        ev.kind = KIND_MERGE_STARTED.into();
1634        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1635        ev.data = serde_json::json!({
1636            "op_id": op_id,
1637            "source_branch": "main",
1638            "worker_branch": "wt/worker",
1639            "expected_source_oid": expected,
1640            "worker_oid": "cafebabecafebabecafebabecafebabecafebabe",
1641            "base_sha": null,
1642            "driver_pid": 4242,
1643            "driver_pid_start_secs": null,
1644            "started_at": "2026-08-15T00:00:00Z",
1645        });
1646        ev
1647    }
1648
1649    /// `merge.started` records the in-flight transaction on `pending_merge`
1650    /// without transitioning the node's status.
1651    #[test]
1652    fn merge_started_records_pending_transaction() {
1653        let tmp = TempDir::new().unwrap();
1654        let run_id = "01jxsnap000000000000000000";
1655        let paths = seed_run_with_node(&tmp, run_id);
1656
1657        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
1658        let n = read_n0001(&paths);
1659        assert_eq!(
1660            n.status,
1661            Status::Pending,
1662            "recording a merge is not terminal"
1663        );
1664        let txn = n.pending_merge.expect("transaction recorded");
1665        assert_eq!(txn.op_id, "op-1");
1666        assert_eq!(txn.expected_source_oid, "aaa");
1667    }
1668
1669    /// `merge.aborted` clears the pending transaction it names, leaving the node
1670    /// live; a stale abort for a different `op_id` is a clean no-op.
1671    #[test]
1672    fn merge_aborted_clears_matching_transaction_only() {
1673        let tmp = TempDir::new().unwrap();
1674        let run_id = "01jxsnap000000000000000000";
1675        let paths = seed_run_with_node(&tmp, run_id);
1676        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
1677
1678        // A stale abort for a different op_id does nothing.
1679        let mut stale = event(run_id);
1680        stale.seq = 4;
1681        stale.kind = KIND_MERGE_ABORTED.into();
1682        stale.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1683        stale.data = serde_json::json!({ "op_id": "op-OTHER", "reason": "x" });
1684        apply_event(&paths, &stale).unwrap();
1685        assert!(
1686            read_n0001(&paths).pending_merge.is_some(),
1687            "stale abort is a no-op"
1688        );
1689
1690        // The matching abort clears it; the node stays live.
1691        let mut abort = event(run_id);
1692        abort.seq = 5;
1693        abort.kind = KIND_MERGE_ABORTED.into();
1694        abort.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1695        abort.data = serde_json::json!({ "op_id": "op-1", "reason": "no mutation" });
1696        apply_event(&paths, &abort).unwrap();
1697        let n = read_n0001(&paths);
1698        assert!(
1699            n.pending_merge.is_none(),
1700            "matching abort clears the transaction"
1701        );
1702        assert_eq!(n.status, Status::Pending, "abort does not terminalize");
1703    }
1704
1705    /// A terminal `node.report` (the normal, no-crash completion) clears any
1706    /// pending merge transaction.
1707    #[test]
1708    fn terminal_report_clears_pending_merge() {
1709        let tmp = TempDir::new().unwrap();
1710        let run_id = "01jxsnap000000000000000000";
1711        let paths = seed_run_with_node(&tmp, run_id);
1712        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
1713
1714        let mut report = event(run_id);
1715        report.seq = 4;
1716        report.kind = "node.report".into();
1717        report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1718        report.data = serde_json::json!({ "success": true, "via": "explicit-merge" });
1719        apply_event(&paths, &report).unwrap();
1720        let n = read_n0001(&paths);
1721        assert_eq!(n.status, Status::Done);
1722        assert!(
1723            n.pending_merge.is_none(),
1724            "completed merge clears the transaction"
1725        );
1726    }
1727
1728    /// Regression (issue `retire-via-string`): the terminal-node adoption
1729    /// exception now keys on the typed `RunMerge` origin, NOT a forgeable `via`
1730    /// string. A late report against a `Failed` node that carries an `Agent`
1731    /// origin (as every `node report` self-submission does) plus a forged
1732    /// `via: "explicit-merge"` must NOT be adopted — the node stays `Failed`. A
1733    /// present-but-malformed origin is likewise not adopted. Only a genuine
1734    /// `RunMerge`-origin report (or a legacy report with NO origin field) is
1735    /// adopted and corrects the node to `Done`.
1736    #[test]
1737    fn late_merge_adoption_requires_run_merge_origin_not_forged_via() {
1738        let tmp = TempDir::new().unwrap();
1739
1740        // Helper: seed a fresh run (distinct id), drive n-0001 to Failed, apply a
1741        // late report, and return the resulting node status.
1742        let drive = |run_id: &str, report_data: Value| -> Status {
1743            let paths = seed_run_with_node(&tmp, run_id);
1744            // Terminalize the node as Failed (a watchdog-synthesized failure).
1745            let mut fail = event(run_id);
1746            fail.seq = 3;
1747            fail.kind = "node.status".into();
1748            fail.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1749            fail.data = serde_json::json!({ "status": "failed" });
1750            apply_event(&paths, &fail).unwrap();
1751            assert_eq!(read_n0001(&paths).status, Status::Failed);
1752            // The late report under test.
1753            let mut report = event(run_id);
1754            report.seq = 4;
1755            report.kind = "node.report".into();
1756            report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1757            report.data = report_data;
1758            apply_event(&paths, &report).unwrap();
1759            read_n0001(&paths).status
1760        };
1761
1762        // Forged: Agent origin + a hand-set `via` — NOT adopted, stays Failed.
1763        let mut agent_forged = serde_json::json!({ "success": true, "via": "explicit-merge" });
1764        crate::ReportOrigin::Agent.stamp(&mut agent_forged);
1765        assert_eq!(
1766            drive("01jxsnap000000000000000001", agent_forged),
1767            Status::Failed,
1768            "an Agent-origin report with a forged via must not be adopted"
1769        );
1770
1771        // Present-but-malformed origin + forged via — NOT adopted, stays Failed.
1772        let malformed = serde_json::json!({
1773            "success": true, "via": "explicit-merge", "origin": "garbage-not-an-object"
1774        });
1775        assert_eq!(
1776            drive("01jxsnap000000000000000002", malformed),
1777            Status::Failed,
1778            "a malformed origin must not re-unlock the legacy via adoption path"
1779        );
1780
1781        // Genuine RunMerge origin (no `via` at all) — adopted, corrected to Done.
1782        let mut run_merge = serde_json::json!({ "success": true });
1783        crate::ReportOrigin::RunMerge {
1784            op_id: Some("op-1".into()),
1785            worker_oid: Some("cafebabe".into()),
1786        }
1787        .stamp(&mut run_merge);
1788        assert_eq!(
1789            drive("01jxsnap000000000000000003", run_merge),
1790            Status::Done,
1791            "a genuine RunMerge-origin report is adopted and corrects Failed→Done"
1792        );
1793
1794        // Legacy report (no origin field) with `via` — still adopted (backward
1795        // compat with pre-typed-origin on-disk runs).
1796        let legacy = serde_json::json!({ "success": true, "via": "explicit-merge" });
1797        assert_eq!(
1798            drive("01jxsnap000000000000000004", legacy),
1799            Status::Done,
1800            "a legacy via-only report (no origin field) is still adopted"
1801        );
1802    }
1803
1804    /// A terminal `node.status` (e.g. a watchdog-synthesized failure) clears any
1805    /// in-flight merge transaction, so `pending_merge` is never stranded on a
1806    /// terminal node where recovery would refuse to look (/llm-review finding).
1807    #[test]
1808    fn terminal_node_status_clears_pending_merge() {
1809        let tmp = TempDir::new().unwrap();
1810        let run_id = "01jxsnap000000000000000000";
1811        let paths = seed_run_with_node(&tmp, run_id);
1812        apply_event(&paths, &merge_started_event(run_id, 3, "op-1", "aaa")).unwrap();
1813        assert!(read_n0001(&paths).pending_merge.is_some());
1814
1815        let mut status = event(run_id);
1816        status.seq = 4;
1817        status.kind = "node.status".into();
1818        status.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1819        status.data = serde_json::json!({ "status": "failed" });
1820        apply_event(&paths, &status).unwrap();
1821        let n = read_n0001(&paths);
1822        assert_eq!(n.status, Status::Failed);
1823        assert!(
1824            n.pending_merge.is_none(),
1825            "terminal status clears the transaction"
1826        );
1827    }
1828
1829    /// Replaying `supervisor.attached` from scratch reproduces
1830    /// `Node.supervisor_pid` — the field is now event-sourced, not a
1831    /// projection-only write (issue `supervisor-state-not-event-sourced`).
1832    #[test]
1833    fn supervisor_attached_sets_supervisor_pid() {
1834        let tmp = TempDir::new().unwrap();
1835        let run_id = "01jxsnap000000000000000000";
1836        let paths = seed_run_with_node(&tmp, run_id);
1837        assert_eq!(read_n0001(&paths).supervisor_pid, None);
1838
1839        let mut ev = event(run_id);
1840        ev.seq = 3;
1841        ev.kind = "supervisor.attached".into();
1842        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1843        ev.data = serde_json::json!({ "pid": 47820 });
1844        apply_event(&paths, &ev).expect("supervisor.attached applies");
1845        assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
1846    }
1847
1848    /// A second attach with a different pid overrides (latest-wins); a replay
1849    /// of the *same* pid is a pure no-op that does not churn `updated_at`.
1850    #[test]
1851    fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
1852        let tmp = TempDir::new().unwrap();
1853        let run_id = "01jxsnap000000000000000000";
1854        let paths = seed_run_with_node(&tmp, run_id);
1855
1856        let mut ev = event(run_id);
1857        ev.kind = "supervisor.attached".into();
1858        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1859
1860        ev.seq = 3;
1861        ev.data = serde_json::json!({ "pid": 100 });
1862        apply_event(&paths, &ev).expect("first attach applies");
1863        assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
1864
1865        // A restart binds a fresh pid: latest-wins.
1866        ev.seq = 4;
1867        ev.data = serde_json::json!({ "pid": 200 });
1868        apply_event(&paths, &ev).expect("second attach applies");
1869        let after_second = read_n0001(&paths);
1870        assert_eq!(after_second.supervisor_pid, Some(200));
1871
1872        // Replaying the latest event again is a no-op: the planned ops are
1873        // empty and the projection bytes (including `updated_at`) are unchanged.
1874        let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1875        assert!(ops.is_empty(), "re-applying same pid must plan no ops");
1876        apply_event(&paths, &ev).expect("replay applies as no-op");
1877        assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
1878    }
1879
1880    /// Replaying `supervisor.cursor_advanced` from scratch reproduces
1881    /// `Node.last_processed_report_seq_by_child`.
1882    #[test]
1883    fn supervisor_cursor_advanced_sets_report_cursor() {
1884        let tmp = TempDir::new().unwrap();
1885        let run_id = "01jxsnap000000000000000000";
1886        let paths = seed_run_with_node(&tmp, run_id);
1887        let child = "02jxsnap000000000000000000";
1888
1889        let mut ev = event(run_id);
1890        ev.seq = 3;
1891        ev.kind = "supervisor.cursor_advanced".into();
1892        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1893        ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
1894        apply_event(&paths, &ev).expect("cursor_advanced applies");
1895
1896        let n = read_n0001(&paths);
1897        assert_eq!(
1898            n.last_processed_report_seq_by_child.get(child),
1899            Some(&Value::from(7u64))
1900        );
1901    }
1902
1903    /// The cursor is monotonic and idempotent: re-applying the same
1904    /// `(child_run_id, report_seq)` is a no-op, an older seq never moves the
1905    /// cursor backward, and a higher seq advances it. A second distinct child
1906    /// gets its own independent entry.
1907    #[test]
1908    fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
1909        let tmp = TempDir::new().unwrap();
1910        let run_id = "01jxsnap000000000000000000";
1911        let paths = seed_run_with_node(&tmp, run_id);
1912        let child_a = "02jxsnap000000000000000000";
1913        let child_b = "03jxsnap000000000000000000";
1914
1915        let mut ev = event(run_id);
1916        ev.kind = "supervisor.cursor_advanced".into();
1917        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1918
1919        ev.seq = 3;
1920        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
1921        apply_event(&paths, &ev).expect("seq 5 applies");
1922
1923        // Replay the exact same event — no-op, plans zero ops.
1924        let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1925        assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
1926
1927        // An older seq must not move the cursor backward.
1928        ev.seq = 4;
1929        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
1930        let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
1931        assert!(ops.is_empty(), "older seq must plan no ops");
1932        apply_event(&paths, &ev).expect("older seq applies as no-op");
1933        assert_eq!(
1934            read_n0001(&paths)
1935                .last_processed_report_seq_by_child
1936                .get(child_a),
1937            Some(&Value::from(5u64))
1938        );
1939
1940        // A higher seq advances; an independent child gets its own entry.
1941        ev.seq = 5;
1942        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
1943        apply_event(&paths, &ev).expect("higher seq applies");
1944        ev.seq = 6;
1945        ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
1946        apply_event(&paths, &ev).expect("second child applies");
1947
1948        let n = read_n0001(&paths);
1949        assert_eq!(
1950            n.last_processed_report_seq_by_child.get(child_a),
1951            Some(&Value::from(9u64))
1952        );
1953        assert_eq!(
1954            n.last_processed_report_seq_by_child.get(child_b),
1955            Some(&Value::from(1u64))
1956        );
1957    }
1958
1959    /// Both new kinds reject a malformed payload at the reducer boundary so a
1960    /// forged event can never write a corrupt projection.
1961    #[test]
1962    fn supervisor_state_events_reject_malformed_payloads() {
1963        let tmp = TempDir::new().unwrap();
1964        let run_id = "01jxsnap000000000000000000";
1965        let paths = seed_run_with_node(&tmp, run_id);
1966        let nid = Some(NodeId::parse_str("n-0001").unwrap());
1967
1968        // Missing pid.
1969        let mut ev = event(run_id);
1970        ev.seq = 3;
1971        ev.kind = "supervisor.attached".into();
1972        ev.node_id = nid.clone();
1973        ev.data = serde_json::json!({});
1974        assert!(matches!(
1975            reduce_event_to_ops(&paths, &ev),
1976            Err(Error::CorruptEventLog { .. })
1977        ));
1978
1979        // Missing envelope node_id.
1980        ev.node_id = None;
1981        ev.data = serde_json::json!({ "pid": 1 });
1982        assert!(matches!(
1983            reduce_event_to_ops(&paths, &ev),
1984            Err(Error::CorruptEventLog { .. })
1985        ));
1986
1987        // cursor_advanced: malformed child_run_id.
1988        let mut ev2 = event(run_id);
1989        ev2.seq = 4;
1990        ev2.kind = "supervisor.cursor_advanced".into();
1991        ev2.node_id = nid.clone();
1992        ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
1993        assert!(matches!(
1994            reduce_event_to_ops(&paths, &ev2),
1995            Err(Error::CorruptEventLog { .. })
1996        ));
1997
1998        // cursor_advanced: missing report_seq.
1999        ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
2000        assert!(matches!(
2001            reduce_event_to_ops(&paths, &ev2),
2002            Err(Error::CorruptEventLog { .. })
2003        ));
2004    }
2005
2006    /// The append gate stays fail-closed after the 0.2 cut added
2007    /// `Kind`'s `#[serde(other)]` catch-all: a `run.created` / `node.created`
2008    /// whose `kind` is a removed kind (`code`, …) or plain garbage must still be
2009    /// rejected as `CorruptEventLog`, NOT silently accepted as `Kind::Unknown`.
2010    /// (Legacy runs are never re-created through the reducer — their manifest is
2011    /// read directly from disk via the permissive `Kind::Unknown` decode.)
2012    #[test]
2013    fn removed_or_garbage_kind_in_created_events_is_rejected() {
2014        let tmp = TempDir::new().unwrap();
2015        let run_id = "01jxsnap000000000000000000";
2016        let rid = RunId::parse_str(run_id).unwrap();
2017        let dir = crate::run_dir(tmp.path(), &rid);
2018        std::fs::create_dir_all(&dir).unwrap();
2019        let paths = RunPaths::new(dir, run_id).unwrap();
2020
2021        for bad in ["code", "orchestrate", "bugfix", "make-skill", "garbage"] {
2022            let mut ev = event(run_id);
2023            ev.kind = "run.created".into();
2024            ev.node_id = None;
2025            ev.data = serde_json::json!({ "kind": bad, "lifecycle": "autonomous", "title": "t" });
2026            assert!(
2027                matches!(
2028                    reduce_event_to_ops(&paths, &ev),
2029                    Err(Error::CorruptEventLog { .. })
2030                ),
2031                "run.created with kind {bad:?} must be rejected, not folded to Unknown"
2032            );
2033        }
2034
2035        // A surviving creatable kind still folds cleanly (guards against a
2036        // false positive that rejects everything).
2037        let mut ok = event(run_id);
2038        ok.kind = "run.created".into();
2039        ok.node_id = None;
2040        ok.data = serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
2041        assert!(reduce_event_to_ops(&paths, &ok).is_ok());
2042    }
2043
2044    /// Snapshot every projection file under `paths` to a `path → inode` map.
2045    ///
2046    /// An atomic projection write is temp-file + rename, so a rewritten file
2047    /// always lands a *fresh inode* — even when its bytes are byte-for-byte
2048    /// identical (e.g. a manifest op that refreshes `updated_at` to the same
2049    /// timestamp). Comparing inodes therefore detects every write the reducer
2050    /// makes, with no false negatives a content diff would suffer. `events.jsonl`
2051    /// and `.lock` are excluded: `apply_event` never touches them.
2052    #[cfg(unix)]
2053    fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
2054        use std::os::unix::fs::MetadataExt;
2055        let mut consider = vec![paths.manifest()];
2056        for dir in [paths.nodes_dir()] {
2057            if let Ok(rd) = std::fs::read_dir(&dir) {
2058                for ent in rd.flatten() {
2059                    let p = ent.path();
2060                    if p.extension().and_then(|s| s.to_str()) == Some("json") {
2061                        consider.push(p);
2062                    }
2063                }
2064            }
2065        }
2066        let mut map = std::collections::BTreeMap::new();
2067        for p in consider {
2068            if let Ok(md) = std::fs::symlink_metadata(&p) {
2069                if md.file_type().is_file() {
2070                    map.insert(p, md.ino());
2071                }
2072            }
2073        }
2074        map
2075    }
2076
2077    /// The exhaustive parity guarantee `projected-paths-into-reducer` requires:
2078    /// for an event applied against a given state, the paths
2079    /// [`plan_projections`] reports MUST equal the files [`apply_event`]
2080    /// actually writes. Plan first (against pre-apply state), apply, then diff
2081    /// the projection inodes — a file is "written" iff it is newly present or
2082    /// its inode changed. `expect_writes` guards the test itself: when set, the
2083    /// touched set must be non-empty, so a kind that silently stopped writing
2084    /// can't pass by matching an empty plan against an empty diff.
2085    #[cfg(unix)]
2086    fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
2087        use std::collections::BTreeSet;
2088        let before = projection_inodes(paths);
2089        let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
2090            .unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
2091            .into_iter()
2092            .collect();
2093        apply_event(paths, ev)
2094            .unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
2095        let after = projection_inodes(paths);
2096        let touched: BTreeSet<PathBuf> = after
2097            .iter()
2098            .filter(|(p, ino)| before.get(*p) != Some(*ino))
2099            .map(|(p, _)| p.clone())
2100            .collect();
2101        assert_eq!(
2102            planned, touched,
2103            "kind={}: plan_projections must name exactly the files apply_event writes",
2104            ev.kind
2105        );
2106        if expect_writes {
2107            assert!(
2108                !touched.is_empty(),
2109                "kind={}: expected this event to write at least one projection",
2110                ev.kind
2111            );
2112        }
2113    }
2114
2115    /// Drive every event kind through a dependency-ordered lifecycle on real
2116    /// runs, asserting plan/apply parity at each step. Covers the writing kinds
2117    /// (run/node/supervisor/child) in states where they
2118    /// project, plus the no-op kinds (audit records, `supervisor.exited`,
2119    /// terminal-guarded transitions) where both the plan and the apply touch
2120    /// nothing.
2121    #[cfg(unix)]
2122    #[test]
2123    fn plan_projections_matches_apply_for_every_kind() {
2124        let tmp = TempDir::new().unwrap();
2125        let run_id = "01jxsnap000000000000000000";
2126        let rid = RunId::parse_str(run_id).unwrap();
2127        let dir = crate::run_dir(tmp.path(), &rid);
2128        std::fs::create_dir_all(&dir).unwrap();
2129        let paths = RunPaths::new(dir, run_id).unwrap();
2130        let nid = || Some(NodeId::parse_str("n-0001").unwrap());
2131        let child = "02jxsnap000000000000000000";
2132
2133        // Helper to build a fresh envelope at a monotonic seq.
2134        let mut next_seq = 0u64;
2135        let mut at = |kind: &str, node_id, data| {
2136            next_seq += 1;
2137            Event {
2138                ts: Utc::now(),
2139                seq: next_seq,
2140                kind: kind.into(),
2141                run_id: rid.clone(),
2142                node_id,
2143                idempotency_key: None,
2144                data,
2145            }
2146        };
2147
2148        // run.created → manifest.json
2149        assert_plan_matches_apply(
2150            &paths,
2151            &at(
2152                "run.created",
2153                None,
2154                serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
2155            ),
2156            true,
2157        );
2158        // run.status (pending → running) → manifest.json
2159        assert_plan_matches_apply(
2160            &paths,
2161            &at(
2162                "run.status",
2163                None,
2164                serde_json::json!({ "status": "running" }),
2165            ),
2166            true,
2167        );
2168        // node.created → nodes/n-0001.json + manifest.json
2169        assert_plan_matches_apply(
2170            &paths,
2171            &at(
2172                "node.created",
2173                nid(),
2174                serde_json::json!({ "kind": "spinoff" }),
2175            ),
2176            true,
2177        );
2178        // node.status (pending → running) → nodes/n-0001.json
2179        assert_plan_matches_apply(
2180            &paths,
2181            &at(
2182                "node.status",
2183                nid(),
2184                serde_json::json!({ "status": "running" }),
2185            ),
2186            true,
2187        );
2188        // supervisor.attached → nodes/n-0001.json (still non-terminal)
2189        assert_plan_matches_apply(
2190            &paths,
2191            &at(
2192                "supervisor.attached",
2193                nid(),
2194                serde_json::json!({ "pid": 4242 }),
2195            ),
2196            true,
2197        );
2198        // supervisor.cursor_advanced → nodes/n-0001.json
2199        assert_plan_matches_apply(
2200            &paths,
2201            &at(
2202                "supervisor.cursor_advanced",
2203                nid(),
2204                serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
2205            ),
2206            true,
2207        );
2208        // child.spawned → nodes/n-0001.json (parent node)
2209        assert_plan_matches_apply(
2210            &paths,
2211            &at(
2212                "child.spawned",
2213                nid(),
2214                serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
2215            ),
2216            true,
2217        );
2218        // node.report success → nodes/n-0001.json (now terminal)
2219        assert_plan_matches_apply(
2220            &paths,
2221            &at("node.report", nid(), serde_json::json!({ "success": true })),
2222            true,
2223        );
2224        // Terminal-guarded no-ops: a settled node swallows further transitions,
2225        // so both the plan and the apply touch nothing.
2226        assert_plan_matches_apply(
2227            &paths,
2228            &at(
2229                "node.status",
2230                nid(),
2231                serde_json::json!({ "status": "failed" }),
2232            ),
2233            false,
2234        );
2235        // No-op audit / lifecycle kinds: zero projections by design.
2236        for kind in [
2237            "supervisor.exited",
2238            "orchestrator.decision",
2239            "discuss.critical",
2240            "cleanup.window_missing",
2241        ] {
2242            assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
2243        }
2244    }
2245
2246    /// A `worker.exited` carrying a clean `exit_code: 0` folds onto the node's
2247    /// `worker_exit` field as a clean exit — and does NOT transition `status`
2248    /// (terminalization is the supervisor's decision via the typed table).
2249    #[test]
2250    fn worker_exited_records_clean_exit_without_transitioning_status() {
2251        let tmp = TempDir::new().unwrap();
2252        let run_id = "01jxsnap000000000000000000";
2253        let paths = bootstrap_retry_node(&tmp, run_id);
2254
2255        let mut ev = event(run_id);
2256        ev.seq = 3;
2257        ev.kind = "worker.exited".into();
2258        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2259        ev.data = serde_json::json!({ "exit_code": 0 });
2260        apply_event(&paths, &ev).expect("worker.exited applies");
2261
2262        let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
2263            .unwrap()
2264            .unwrap();
2265        let exit = n.worker_exit.expect("worker_exit recorded");
2266        assert_eq!(exit.code, Some(0));
2267        assert_eq!(exit.signal, None);
2268        assert!(exit.is_clean());
2269        assert_eq!(
2270            n.status,
2271            Status::Pending,
2272            "the exit fact never transitions status"
2273        );
2274    }
2275
2276    /// A `worker.exited` carrying a `signal` records it as a failure; and the fold
2277    /// is first-write-wins — a replayed/duplicate exit event never overwrites the
2278    /// first recorded fact (replay-safety for the `applied_seq` watermark).
2279    #[test]
2280    fn worker_exited_records_signal_and_is_first_write_wins() {
2281        let tmp = TempDir::new().unwrap();
2282        let run_id = "01jxsnap000000000000000000";
2283        let paths = bootstrap_retry_node(&tmp, run_id);
2284        let nid = NodeId::parse_str("n-0001").unwrap();
2285
2286        let mut ev = event(run_id);
2287        ev.seq = 3;
2288        ev.kind = "worker.exited".into();
2289        ev.node_id = Some(nid.clone());
2290        ev.data = serde_json::json!({ "signal": 9 });
2291        apply_event(&paths, &ev).expect("worker.exited applies");
2292
2293        let n = read_node_opt(&paths, &nid).unwrap().unwrap();
2294        let exit = n.worker_exit.expect("worker_exit recorded");
2295        assert_eq!(exit.signal, Some(9));
2296        assert!(exit.is_failure());
2297
2298        // A later, conflicting exit event (e.g. a replay of a different value) is a
2299        // clean no-op: the first fact stands.
2300        let mut dup = event(run_id);
2301        dup.seq = 4;
2302        dup.kind = "worker.exited".into();
2303        dup.node_id = Some(nid.clone());
2304        dup.data = serde_json::json!({ "exit_code": 0 });
2305        apply_event(&paths, &dup).expect("duplicate worker.exited applies as no-op");
2306        let n2 = read_node_opt(&paths, &nid).unwrap().unwrap();
2307        assert_eq!(
2308            n2.worker_exit.unwrap().signal,
2309            Some(9),
2310            "first-write-wins: the replayed exit must not overwrite the recorded fact"
2311        );
2312    }
2313
2314    /// A `worker.exited` carrying neither `exit_code` nor `signal` is malformed —
2315    /// the reducer is the canonical gate and rejects it as `CorruptEventLog` rather
2316    /// than record an empty fact.
2317    #[test]
2318    fn worker_exited_without_code_or_signal_is_corrupt() {
2319        let tmp = TempDir::new().unwrap();
2320        let run_id = "01jxsnap000000000000000000";
2321        let paths = bootstrap_retry_node(&tmp, run_id);
2322
2323        let mut ev = event(run_id);
2324        ev.seq = 3;
2325        ev.kind = "worker.exited".into();
2326        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
2327        ev.data = serde_json::json!({});
2328        match reduce_event_to_ops(&paths, &ev) {
2329            Err(Error::CorruptEventLog { .. }) => {}
2330            Ok(_) => panic!("an empty worker.exited payload must be rejected, not applied"),
2331            Err(other) => panic!("expected CorruptEventLog, got {other:?}"),
2332        }
2333
2334        // Carrying BOTH is contradictory (a process cannot both return a code and
2335        // be killed) — also rejected.
2336        ev.data = serde_json::json!({ "exit_code": 0, "signal": 9 });
2337        match reduce_event_to_ops(&paths, &ev) {
2338            Err(Error::CorruptEventLog { .. }) => {}
2339            Ok(_) => panic!("a worker.exited with both fields must be rejected"),
2340            Err(other) => panic!("expected CorruptEventLog, got {other:?}"),
2341        }
2342    }
2343
2344    /// `node.death_observed` records the residual crash backstop's first-death
2345    /// anchor (`first_death_at`) as `ev.ts`, is **first-write-wins** (a later
2346    /// re-observation never resets the monotonic anchor), and is a no-op against a
2347    /// terminal node (the backstop is moot once settled). Issue
2348    /// `typed-supervisor-outcomes`.
2349    #[test]
2350    fn node_death_observed_records_first_death_first_write_wins() {
2351        let tmp = TempDir::new().unwrap();
2352        let run_id = "01jxsnap000000000000000000";
2353        let paths = bootstrap_retry_node(&tmp, run_id);
2354        let nid = NodeId::parse_str("n-0001").unwrap();
2355
2356        let mut ev = event(run_id);
2357        ev.seq = 3;
2358        ev.kind = "node.death_observed".into();
2359        ev.node_id = Some(nid.clone());
2360        ev.data = serde_json::json!({});
2361        apply_event(&paths, &ev).expect("node.death_observed applies");
2362        let first = read_node_opt(&paths, &nid)
2363            .unwrap()
2364            .unwrap()
2365            .first_death_at
2366            .expect("first_death_at recorded");
2367        assert_eq!(first, ev.ts, "the anchor is the event's own timestamp");
2368
2369        // A later re-observation is first-write-wins: the monotonic anchor holds.
2370        let mut later = event(run_id);
2371        later.seq = 4;
2372        later.kind = "node.death_observed".into();
2373        later.node_id = Some(nid.clone());
2374        later.ts = ev.ts + chrono::Duration::seconds(30);
2375        later.data = serde_json::json!({});
2376        apply_event(&paths, &later).expect("re-observation applies as no-op");
2377        assert_eq!(
2378            read_node_opt(&paths, &nid).unwrap().unwrap().first_death_at,
2379            Some(first),
2380            "first-write-wins: a re-observation must not reset the anchor"
2381        );
2382    }
2383
2384    /// `node.death_observed` is a no-op once a higher-fidelity fact exists — here a
2385    /// told `worker.exited` — so a from-scratch replay converges to the same state
2386    /// the supervisor's lock-guarded emitter would produce (the backstop is moot
2387    /// once the shim recorded a real exit). Issue `typed-supervisor-outcomes`.
2388    #[test]
2389    fn node_death_observed_noop_when_worker_exit_present() {
2390        let tmp = TempDir::new().unwrap();
2391        let run_id = "01jxsnap000000000000000000";
2392        let paths = bootstrap_retry_node(&tmp, run_id);
2393        let nid = NodeId::parse_str("n-0001").unwrap();
2394
2395        // A told exit lands first.
2396        let mut exit = event(run_id);
2397        exit.seq = 3;
2398        exit.kind = "worker.exited".into();
2399        exit.node_id = Some(nid.clone());
2400        exit.data = serde_json::json!({ "exit_code": 0 });
2401        apply_event(&paths, &exit).unwrap();
2402
2403        // A death observation for the same node folds to nothing.
2404        let mut death = event(run_id);
2405        death.seq = 4;
2406        death.kind = "node.death_observed".into();
2407        death.node_id = Some(nid.clone());
2408        death.data = serde_json::json!({});
2409        apply_event(&paths, &death).expect("applies as no-op");
2410        assert_eq!(
2411            read_node_opt(&paths, &nid).unwrap().unwrap().first_death_at,
2412            None,
2413            "a told worker.exited makes the crash backstop moot; no anchor recorded"
2414        );
2415    }
2416
2417    /// `node.retry` clears the previous attempt's told exit fact: the re-spawned
2418    /// worker is a NEW process, so a stale `worker_exit` must not carry over (it
2419    /// would make the supervisor mis-judge the fresh attempt from the dead one's
2420    /// exit). Issue `thin-exit-status-launcher`.
2421    #[test]
2422    fn node_retry_clears_worker_exit() {
2423        let tmp = TempDir::new().unwrap();
2424        let run_id = "01jxsnap000000000000000000";
2425        let paths = bootstrap_retry_node(&tmp, run_id);
2426        let nid = NodeId::parse_str("n-0001").unwrap();
2427
2428        // Record a failing exit on the first attempt.
2429        let mut exit = event(run_id);
2430        exit.seq = 3;
2431        exit.kind = "worker.exited".into();
2432        exit.node_id = Some(nid.clone());
2433        exit.data = serde_json::json!({ "exit_code": 7 });
2434        apply_event(&paths, &exit).unwrap();
2435        assert!(read_node_opt(&paths, &nid)
2436            .unwrap()
2437            .unwrap()
2438            .worker_exit
2439            .is_some());
2440
2441        // Retry re-spawns the node — the stale exit fact must be gone.
2442        let mut retry = event(run_id);
2443        retry.seq = 4;
2444        retry.kind = "node.retry".into();
2445        retry.node_id = Some(nid.clone());
2446        retry.data = serde_json::json!({
2447            "attempt": 1,
2448            "reason": "agent-died",
2449            "branch": "wt/foo",
2450            "worktree_path": "/tmp/new-wt",
2451            "agent_pid": 222,
2452        });
2453        apply_event(&paths, &retry).unwrap();
2454
2455        let n = read_node_opt(&paths, &nid).unwrap().unwrap();
2456        assert!(
2457            n.worker_exit.is_none(),
2458            "node.retry must clear the previous attempt's worker_exit"
2459        );
2460        assert_eq!(
2461            n.status,
2462            Status::Pending,
2463            "retry returns the node to Pending"
2464        );
2465    }
2466}