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//! counters (`node_count`, `open_discussions`, `pending_spinoffs`). Those are
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::{
56    read_discussion_opt, read_manifest_opt, read_node_opt, read_spinoff_opt, write_discussion,
57    write_manifest, write_node, write_spinoff,
58};
59use crate::schema::{
60    ChildRef, Discussion, DiscussionId, DiscussionStatus, Event, IdValidationError, Kind,
61    Lifecycle, Manifest, Node, NodeId, ProposalId, RunId, SpinoffProposal, SpinoffStatus, Status,
62    TmuxIdentity, STATE_SCHEMA_VERSION,
63};
64
65/// Map an id-validation failure on an event-sourced id to a [`CorruptEventLog`]
66/// error. An id that fails to parse here came off `events.jsonl` (or a forged
67/// event), so the log — not the caller — is the corrupt party.
68///
69/// [`CorruptEventLog`]: Error::CorruptEventLog
70fn corrupt_id(events_path: &Path, ev: &Event, e: &IdValidationError) -> Error {
71    Error::CorruptEventLog {
72        path: events_path.to_path_buf(),
73        reason: format!("event seq={} kind={}: {e}", ev.seq, ev.kind),
74    }
75}
76
77/// Parse an optional `RunId` from event-data field `field`: missing/null →
78/// `None`; a JSON string → validated `Some(RunId)`; a malformed id or a
79/// non-string value → [`Error::CorruptEventLog`].
80fn opt_run_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<RunId>> {
81    match d.get(field) {
82        None | Some(Value::Null) => Ok(None),
83        Some(Value::String(s)) => RunId::parse_str(s)
84            .map(Some)
85            .map_err(|e| corrupt_id(events_path, ev, &e)),
86        Some(_) => Err(Error::CorruptEventLog {
87            path: events_path.to_path_buf(),
88            reason: format!(
89                "event seq={} kind={} `{field}` must be a JSON string or null",
90                ev.seq, ev.kind
91            ),
92        }),
93    }
94}
95
96/// Parse an optional `NodeId` from event-data field `field`. See [`opt_run_id`].
97fn opt_node_id(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<NodeId>> {
98    match d.get(field) {
99        None | Some(Value::Null) => Ok(None),
100        Some(Value::String(s)) => NodeId::parse_str(s)
101            .map(Some)
102            .map_err(|e| corrupt_id(events_path, ev, &e)),
103        Some(_) => Err(Error::CorruptEventLog {
104            path: events_path.to_path_buf(),
105            reason: format!(
106                "event seq={} kind={} `{field}` must be a JSON string or null",
107                ev.seq, ev.kind
108            ),
109        }),
110    }
111}
112
113/// Resolve a required `NodeId` from event-data field `field`, falling back to
114/// the envelope's top-level `node_id`. Used where the node reference may appear
115/// either in `data` or on the envelope (discussion/spinoff `node_id`).
116fn want_node_id_with_fallback(
117    events_path: &Path,
118    ev: &Event,
119    d: &Value,
120    field: &str,
121) -> Result<NodeId> {
122    let s = d
123        .get(field)
124        .and_then(Value::as_str)
125        .or(ev.node_id.as_ref().map(NodeId::as_str))
126        .ok_or_else(|| Error::CorruptEventLog {
127            path: events_path.to_path_buf(),
128            reason: format!("event seq={} kind={} missing `{field}`", ev.seq, ev.kind),
129        })?;
130    NodeId::parse_str(s).map_err(|e| corrupt_id(events_path, ev, &e))
131}
132
133fn data_kind(v: &Value) -> Option<Kind> {
134    serde_json::from_value(v.clone()).ok()
135}
136
137fn data_status(v: &Value) -> Option<Status> {
138    serde_json::from_value(v.clone()).ok()
139}
140
141fn require_status(ev: &Event, path: PathBuf) -> Result<Status> {
142    data_status(ev.data.get("status").unwrap_or(&Value::Null)).ok_or_else(|| {
143        Error::CorruptEventLog {
144            path,
145            reason: format!("{} missing/invalid `status`", ev.kind),
146        }
147    })
148}
149
150fn want_str<'a>(events_path: &Path, ev: &Event, d: &'a Value, field: &str) -> Result<&'a str> {
151    d.get(field)
152        .and_then(Value::as_str)
153        .ok_or_else(|| Error::CorruptEventLog {
154            path: events_path.to_path_buf(),
155            reason: format!(
156                "event seq={} kind={} missing `{field}` string field",
157                ev.seq, ev.kind
158            ),
159        })
160}
161
162/// Read an optional string field with strict typing: missing/null → `None`,
163/// JSON string → `Some(s)`, anything else → `CorruptEventLog`. Prevents
164/// the reducer from silently dropping non-string payload values.
165fn optional_str(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<String>> {
166    match d.get(field) {
167        None | Some(Value::Null) => Ok(None),
168        Some(Value::String(s)) => Ok(Some(s.clone())),
169        Some(_) => Err(Error::CorruptEventLog {
170            path: events_path.to_path_buf(),
171            reason: format!(
172                "event seq={} kind={} `{field}` must be a JSON string or null",
173                ev.seq, ev.kind
174            ),
175        }),
176    }
177}
178
179/// Read an optional boolean field with strict typing: missing/null → `None`,
180/// JSON bool → `Some(b)`, anything else → `CorruptEventLog`. Mirrors
181/// [`optional_str`] / [`optional_i32`]; prevents a non-boolean `success` /
182/// `cancelled` from being silently coerced to `false` and bypassing the
183/// success-XOR-cancelled invariant.
184fn optional_bool(events_path: &Path, ev: &Event, d: &Value, field: &str) -> Result<Option<bool>> {
185    match d.get(field) {
186        None | Some(Value::Null) => Ok(None),
187        Some(Value::Bool(b)) => Ok(Some(*b)),
188        Some(_) => Err(Error::CorruptEventLog {
189            path: events_path.to_path_buf(),
190            reason: format!(
191                "event seq={} kind={} `{field}` must be a JSON boolean or null",
192                ev.seq, ev.kind
193            ),
194        }),
195    }
196}
197
198fn optional_i32(d: &Value, field: &str, events_path: &Path, ev: &Event) -> Result<Option<i32>> {
199    match d.get(field) {
200        None | Some(Value::Null) => Ok(None),
201        Some(v) => {
202            let raw = v.as_i64().ok_or_else(|| Error::CorruptEventLog {
203                path: events_path.to_path_buf(),
204                reason: format!(
205                    "event seq={} kind={} `{field}` must be integer",
206                    ev.seq, ev.kind
207                ),
208            })?;
209            i32::try_from(raw)
210                .map(Some)
211                .map_err(|_| Error::CorruptEventLog {
212                    path: events_path.to_path_buf(),
213                    reason: format!(
214                        "event seq={} kind={} `{field}` out of i32 range: {raw}",
215                        ev.seq, ev.kind
216                    ),
217                })
218        }
219    }
220}
221
222fn optional_ts(
223    d: &Value,
224    field: &str,
225    events_path: &Path,
226    ev: &Event,
227) -> Result<Option<DateTime<Utc>>> {
228    match d.get(field) {
229        None | Some(Value::Null) => Ok(None),
230        Some(Value::String(s)) => DateTime::parse_from_rfc3339(s)
231            .map(|dt| Some(dt.with_timezone(&Utc)))
232            .map_err(|_| Error::CorruptEventLog {
233                path: events_path.to_path_buf(),
234                reason: format!(
235                    "event seq={} kind={} `{field}` not RFC3339",
236                    ev.seq, ev.kind
237                ),
238            }),
239        Some(_) => Err(Error::CorruptEventLog {
240            path: events_path.to_path_buf(),
241            reason: format!(
242                "event seq={} kind={} `{field}` must be RFC3339 string or null",
243                ev.seq, ev.kind
244            ),
245        }),
246    }
247}
248
249/// A projection write planned by [`reduce_event_to_ops`] and performed by
250/// [`commit_ops`].
251///
252/// Splitting the reducer into a pure *plan* phase (compute these ops from the
253/// current projection state, validating as it goes) and a *commit* phase
254/// (write them) means a single branch per kind implements both the pre-append
255/// validation gate and the post-append apply — there is no validate/apply
256/// mirror to drift out of lockstep, and the projection state is read once
257/// rather than twice.
258pub(crate) enum ProjectionOp {
259    /// Write the run manifest.
260    Manifest(Manifest),
261    /// Write a node projection.
262    Node(Node),
263    /// Write a discussion projection.
264    Discussion(Discussion),
265    /// Write a spinoff-proposal projection.
266    Spinoff(SpinoffProposal),
267}
268
269/// Commit a planned batch of projection writes, in order.
270///
271/// Caller must hold the run's [`crate::lock::RunLock`]. Pairs with
272/// [`reduce_event_to_ops`]: the ops were computed against the same locked
273/// state, and nothing mutates the projections between the plan and this commit
274/// (in the append path only `events.jsonl` is written in between), so the
275/// planned writes are still valid.
276pub(crate) fn commit_ops(paths: &RunPaths, ops: Vec<ProjectionOp>) -> Result<()> {
277    for op in ops {
278        match op {
279            ProjectionOp::Manifest(m) => write_manifest(paths, &m)?,
280            ProjectionOp::Node(n) => write_node(paths, &n)?,
281            ProjectionOp::Discussion(d) => write_discussion(paths, &d)?,
282            ProjectionOp::Spinoff(s) => write_spinoff(paths, &s)?,
283        }
284    }
285    Ok(())
286}
287
288/// Plan the projection writes one event implies, *without* performing them.
289///
290/// This is the single source of truth for both validation and application: it
291/// reads the current projection state, enforces every event-payload invariant
292/// (returning [`Error::CorruptEventLog`] for a malformed or cross-run event),
293/// and returns the exact [`ProjectionOp`]s to commit (empty for a no-op or an
294/// unknown `kind`). Because it never writes, it is also the transactional gate
295/// run *before* the durable append in
296/// [`crate::events::append_and_apply_unlocked`]: a reducer-rejected event is
297/// caught here and never reaches `events.jsonl`, so a later replay /
298/// `rebuild_projections` can't trip over a poison line. The state-dependent
299/// no-op guards live here too (a settled node/run/discussion swallows a late
300/// or even malformed event as a clean no-op rather than erroring).
301///
302/// Caller must hold the run's [`crate::lock::RunLock`].
303pub(crate) fn reduce_event_to_ops(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
304    // An event whose envelope `run_id` doesn't match the run we're folding it
305    // into means the log was copied/misrouted — folding it would silently
306    // cross-contaminate projections. Reject before planning anything.
307    if ev.run_id != paths.run_id {
308        return Err(Error::CorruptEventLog {
309            path: paths.events(),
310            reason: format!(
311                "event seq={} envelope run_id {:?} does not match run {:?}",
312                ev.seq,
313                ev.run_id.as_str(),
314                paths.run_id.as_str()
315            ),
316        });
317    }
318    // Each event kind is listed explicitly as documentation of the known set;
319    // `supervisor.exited` and the `_` fallthrough share a body intentionally.
320    #[allow(clippy::match_same_arms)]
321    match ev.kind.as_str() {
322        "run.created" => reduce_run_created(paths, ev),
323        "run.status" => reduce_run_status(paths, ev),
324        "node.created" => reduce_node_created(paths, ev),
325        "node.status" => reduce_node_status(paths, ev),
326        "node.report" => reduce_node_report(paths, ev),
327        "node.retry" => reduce_node_retry(paths, ev),
328        "discussion.opened" => reduce_discussion_opened(paths, ev),
329        "discussion.resolved" => reduce_discussion_resolved(paths, ev),
330        "spinoff.proposed" => reduce_spinoff_proposed(paths, ev),
331        "spinoff.approved" => reduce_spinoff_approved(paths, ev),
332        "spinoff.rejected" => reduce_spinoff_rejected(paths, ev),
333        "child.spawned" => reduce_child_spawned(paths, ev),
334        "supervisor.attached" => reduce_supervisor_attached(paths, ev),
335        "supervisor.cursor_advanced" => reduce_supervisor_cursor_advanced(paths, ev),
336        "supervisor.exited" => Ok(vec![]),
337        // Append-only audit records from `/orchestrate` (decision log +
338        // pakkopysäytys). They mutate no projection — the event log is their
339        // canonical home — so they fold to a clean no-op. Listed explicitly
340        // (rather than relying on the `_` fallthrough) so the append path's
341        // transactional gate runs the same no-op plan for them and the intent
342        // is documented at the match site. They are NOT `node.report`, so the
343        // supervisor never mistakes them for a terminal signal.
344        "orchestrator.decision" | "discuss.critical" => Ok(vec![]),
345        // At-most-once marker the supervisor appends the first time a run is
346        // observed terminal, gating the `run create --notify` completion hook so
347        // a restart never re-fires it (issue `no-completion-notification-to-parent`).
348        // Mutates no projection — the event log is its only home — so it folds to
349        // a clean no-op. Listed explicitly so the append path's transactional gate
350        // runs the same no-op plan and the intent is documented here.
351        "run.notified" => Ok(vec![]),
352        // Best-effort teardown audit records from the supervisor's cleanup
353        // path. Each mutates no projection — the event log is their only home —
354        // so they fold to a clean no-op. Listed explicitly so the append path's
355        // transactional gate runs the same no-op plan and the intent is
356        // documented here.
357        //   - `cleanup.window_missing`: the node's tmux window could not be
358        //     located to close it (typically a manually-resolved rebase renamed
359        //     the window — issue `worktree-merge-orphans-tmux-window`).
360        //   - `cleanup.worktree_missing`: the worktree dir was already gone at
361        //     teardown (e.g. removed manually), so nothing to `worktree remove`.
362        //   - `cleanup.branch_remove_failed`: `git branch -{d,D}` refused (e.g.
363        //     unmerged commits, or the branch is already gone); the run completes
364        //     anyway (issue `supervisor-worktree-remove-no-force`).
365        //   - `cleanup.branch_preserved`: a BLOCKED terminal report
366        //     (`success: false`, no explicit merge) intentionally left the branch
367        //     and worktree in place for the human to pick up, instead of tearing
368        //     them down (issue `blocked-report-deletes-branch`).
369        //   - `cleanup.session_killed`: the run's managed `--headless` tmux
370        //     session was torn down once its last managed window was gone, so an
371        //     empty session is not left behind (issue
372        //     `headless-tmux-session-not-torn-down`).
373        //   - `cleanup.session_retained`: the same teardown was skipped because a
374        //     human had attached to the session — never yanked out from under
375        //     them.
376        "cleanup.window_missing"
377        | "cleanup.worktree_missing"
378        | "cleanup.branch_remove_failed"
379        | "cleanup.branch_preserved"
380        | "cleanup.session_killed"
381        | "cleanup.session_retained" => Ok(vec![]),
382        // Data-integrity audit record: the supervisor found a persisted child
383        // run id (in `supervisor.state.json`'s `spawned_children`) that fails
384        // `RunId` structural validation and quarantined it — a corrupt id that
385        // would otherwise resolve with `.ok()` and be silently skipped every
386        // tick, indistinguishable from a child that completed and was torn down
387        // (issue `wildly-glorious-food`). It mutates no projection — the event
388        // log is its only home — so it folds to a clean no-op. Listed
389        // explicitly so the append path's transactional gate runs the same
390        // no-op plan and the intent is documented here.
391        "supervisor.child_id_quarantined" => Ok(vec![]),
392        _ => Ok(vec![]),
393    }
394}
395
396/// The projection file [`commit_ops`] writes for `op`, keyed exactly as the
397/// `write_*` helpers key it internally. Shared by [`plan_projections`] (which
398/// reports the path) and conceptually by [`commit_ops`] (which writes it), so
399/// the enumerated path list can never name a different file than the one the
400/// reducer actually fsyncs.
401fn op_path(paths: &RunPaths, op: &ProjectionOp) -> PathBuf {
402    match op {
403        ProjectionOp::Manifest(_) => paths.manifest(),
404        ProjectionOp::Node(n) => paths.node(&n.node_id),
405        ProjectionOp::Discussion(d) => paths.discussion(&d.discussion_id),
406        ProjectionOp::Spinoff(s) => paths.spinoff(&s.proposal_id),
407    }
408}
409
410/// Enumerate the projection files the reducer would write for `event`, in the
411/// order `commit_ops` would write them, *without* performing any write.
412///
413/// This is the single source of truth that ends the CLI/reducer divergence the
414/// `projected-paths-into-reducer` issue describes: rather than a hand-maintained
415/// list in `octl-cli` that drifts whenever a new projection is added, both the
416/// reducer and a caller's preflight (`event create --dry-run`) read the *same*
417/// `reduce_event_to_ops` plan. This function maps that plan to file paths;
418/// `apply_event` commits it. A new projection added to a reducer arm is
419/// therefore reflected here automatically.
420///
421/// Because it runs the real reducer plan against current projection state, the
422/// result is exact, not a guess: a state-dependent no-op (a settled node, an
423/// already-created projection, a terminal-guarded transition) yields an empty
424/// list — precisely the files `apply_event` would touch, which is none. A
425/// malformed-payload event surfaces the same [`Error::CorruptEventLog`] the
426/// real apply would, so a dry-run preflight cannot report success for an event
427/// the write path would reject.
428///
429/// Caller should hold the run's [`crate::lock::RunLock`] for a snapshot
430/// consistent with a concurrent reducer; a lock-free read is best-effort.
431pub fn plan_projections(paths: &RunPaths, event: &Event) -> Result<Vec<PathBuf>> {
432    let ops = reduce_event_to_ops(paths, event)?;
433    Ok(ops.iter().map(|op| op_path(paths, op)).collect())
434}
435
436/// Apply one event to projections: plan via [`reduce_event_to_ops`], then
437/// [`commit_ops`]. No-op for unknown `kind`. Caller must hold the run's
438/// [`crate::lock::RunLock`].
439///
440/// Shares the one [`reduce_event_to_ops`] plan with [`plan_projections`]: the
441/// paths that function reports are exactly the files this one fsyncs, because
442/// both consume the same `ProjectionOp` vector (this commits it; that maps it to
443/// paths via [`op_path`]).
444///
445/// `pub(crate)`: applying an event in isolation (without the matching
446/// `events.jsonl` append) is an internal building block used by `cancel` (to
447/// re-fold a crash-stranded event) and a future `rebuild_projections_from_events`.
448/// External callers mutate state through
449/// [`crate::events::append_and_apply_event`] so the log and projections can
450/// never diverge.
451pub(crate) fn apply_event(paths: &RunPaths, ev: &Event) -> Result<()> {
452    let ops = reduce_event_to_ops(paths, ev)?;
453    commit_ops(paths, ops)
454}
455
456/// Validate an event WITHOUT writing anything — [`reduce_event_to_ops`] with
457/// the planned writes discarded. Returns `Err` in exactly the cases
458/// [`apply_event`] would (they share the one plan), so a dry-run check can
459/// never drift from the apply.
460///
461/// `#[cfg(test)]`: the append path validates by inspecting
462/// `reduce_event_to_ops` directly (it needs the planned ops anyway), so this
463/// discard-the-ops wrapper exists only for the reducer's agreement tests.
464#[cfg(test)]
465pub(crate) fn validate_event(paths: &RunPaths, ev: &Event) -> Result<()> {
466    reduce_event_to_ops(paths, ev).map(|_| ())
467}
468
469/// The envelope `node_id` that a `node.*` event must carry, with the same
470/// `CorruptEventLog` message `apply_*` produces. Shared by validate/apply so
471/// the missing-id check can't drift between them.
472fn require_envelope_node_id(events_path: &Path, ev: &Event) -> Result<NodeId> {
473    ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
474        path: events_path.to_path_buf(),
475        reason: format!(
476            "event seq={} kind={} missing top-level `node_id`",
477            ev.seq, ev.kind
478        ),
479    })
480}
481
482fn reduce_run_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
483    // Idempotent: a replayed `run.created` against an existing manifest is a
484    // no-op (but validates that `run_id` matches; otherwise the event log
485    // is being applied to the wrong run).
486    if let Some(existing) = read_manifest_opt(paths)? {
487        if existing.run_id != ev.run_id {
488            return Err(Error::CorruptEventLog {
489                path: paths.manifest(),
490                reason: format!(
491                    "run.created run_id={} conflicts with existing manifest run_id={}",
492                    ev.run_id, existing.run_id
493                ),
494            });
495        }
496        return Ok(vec![]);
497    }
498    let events_path = paths.events();
499    let d = &ev.data;
500    let kind =
501        data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
502            path: events_path.clone(),
503            reason: "run.created missing/invalid `kind`".into(),
504        })?;
505    let lifecycle: Lifecycle = serde_json::from_value(
506        d.get("lifecycle").cloned().unwrap_or(Value::Null),
507    )
508    .map_err(|_| Error::CorruptEventLog {
509        path: events_path.clone(),
510        reason: "run.created missing/invalid `lifecycle`".into(),
511    })?;
512    let title = want_str(&events_path, ev, d, "title")?.to_string();
513    let m = Manifest {
514        schema_version: STATE_SCHEMA_VERSION,
515        // Created at the watermark floor; the append path advances it to this
516        // event's `seq` (after the manifest is fsynced) in `advance_applied_seq`.
517        applied_seq: 0,
518        // `run_id == paths.run_id` was verified at `reduce_event_to_ops` entry.
519        run_id: paths.run_id.clone(),
520        kind,
521        lifecycle,
522        title,
523        status: Status::Pending,
524        created_at: ev.ts,
525        updated_at: ev.ts,
526        source_repo: d
527            .get("source_repo")
528            .and_then(Value::as_str)
529            .map(str::to_string),
530        source_branch: d
531            .get("source_branch")
532            .and_then(Value::as_str)
533            .map(str::to_string),
534        worktree_root: d
535            .get("worktree_root")
536            .and_then(Value::as_str)
537            .map(str::to_string),
538        managed_tmux_session: d
539            .get("managed_tmux_session")
540            .and_then(Value::as_str)
541            .map(str::to_string),
542        notify_cmd: d
543            .get("notify_cmd")
544            .and_then(Value::as_str)
545            .map(str::to_string),
546        harness: d.get("harness").and_then(Value::as_str).map(str::to_string),
547        node_count: 0,
548        open_discussions: 0,
549        pending_spinoffs: 0,
550        parent_run_id: opt_run_id(&events_path, ev, d, "parent_run_id")?,
551        parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
552    };
553    Ok(vec![ProjectionOp::Manifest(m)])
554}
555
556fn reduce_run_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
557    let mut m = match read_manifest_opt(paths)? {
558        Some(m) => m,
559        None => return Ok(vec![]),
560    };
561    let new_status = require_status(ev, paths.events())?;
562    // Terminal-state guard: a settled run never transitions again (e.g. a
563    // late `run.status running` after a cancel). See run-cli-read/handoff.md D5.
564    if m.status.is_terminal() {
565        trace_terminal_noop(ev, m.status, new_status);
566        return Ok(vec![]);
567    }
568    if m.status == new_status {
569        return Ok(vec![]);
570    }
571    m.status = new_status;
572    m.updated_at = ev.ts;
573    Ok(vec![ProjectionOp::Manifest(m)])
574}
575
576/// Reconstruct the fully-qualified tmux identity from `node.created` event
577/// data. Returns `Some` only when both `tmux_session` and `tmux_window_id` are
578/// present and non-empty — the minimum needed to match a window. `tmux_socket`
579/// is optional (a default-socket spawn may emit null); an empty socket is
580/// normalized to `None` so the watchdog never invokes `tmux -S ""`.
581/// `tmux_pane_id` is likewise optional (create.sh predating it emits nothing);
582/// agent-log capture falls back to the window's active pane when absent. Legacy
583/// events from a create.sh that predates the qualified fields (or that emit a
584/// partial/empty identity) yield `None`, so the node falls back to bare-name
585/// matching on `tmux_window`.
586fn tmux_identity_from_data(d: &Value) -> Option<TmuxIdentity> {
587    let nonempty = |key| {
588        d.get(key)
589            .and_then(Value::as_str)
590            .map(str::trim)
591            .filter(|s| !s.is_empty())
592            .map(str::to_string)
593    };
594    let session = nonempty("tmux_session")?;
595    let window_id = nonempty("tmux_window_id")?;
596    Some(TmuxIdentity {
597        socket: nonempty("tmux_socket"),
598        session,
599        window_id,
600        // Optional: create.sh predating the field (or a failed pane query)
601        // emits no `tmux_pane_id`; capture then falls back to `window_id`.
602        pane_id: nonempty("tmux_pane_id"),
603    })
604}
605
606fn reduce_node_created(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
607    let events_path = paths.events();
608    // The envelope `node_id` is already a validated `NodeId` (parsed on read),
609    // so take it directly — no re-parse needed.
610    let node_id = require_envelope_node_id(&events_path, ev)?;
611    // Idempotent on replay: skip if the node already exists.
612    if read_node_opt(paths, &node_id)?.is_some() {
613        return Ok(vec![]);
614    }
615    let d = &ev.data;
616    let kind =
617        data_kind(d.get("kind").unwrap_or(&Value::Null)).ok_or_else(|| Error::CorruptEventLog {
618            path: events_path.clone(),
619            reason: format!(
620                "event seq={} kind=node.created missing/invalid `kind`",
621                ev.seq
622            ),
623        })?;
624    let n = Node {
625        schema_version: STATE_SCHEMA_VERSION,
626        node_id,
627        // `run_id == paths.run_id` was verified at `reduce_event_to_ops` entry.
628        run_id: paths.run_id.clone(),
629        parent_node_id: opt_node_id(&events_path, ev, d, "parent_node_id")?,
630        kind,
631        status: Status::Pending,
632        task: d.get("task").and_then(Value::as_str).map(str::to_string),
633        worktree_path: d
634            .get("worktree_path")
635            .and_then(Value::as_str)
636            .map(str::to_string),
637        branch: d.get("branch").and_then(Value::as_str).map(str::to_string),
638        base_sha: d
639            .get("base_sha")
640            .and_then(Value::as_str)
641            .filter(|s| !s.is_empty())
642            .map(str::to_string),
643        tmux_window: d
644            .get("tmux_window")
645            .and_then(Value::as_str)
646            .map(str::to_string),
647        tmux_identity: tmux_identity_from_data(d),
648        agent_pid: optional_i32(d, "agent_pid", &events_path, ev)?,
649        agent_pid_start_time: optional_ts(d, "agent_pid_start_time", &events_path, ev)?,
650        supervisor_pid: optional_i32(d, "supervisor_pid", &events_path, ev)?,
651        children: Vec::new(),
652        started_at: Some(ev.ts),
653        updated_at: ev.ts,
654        last_report: None,
655        last_processed_report_seq_by_child: serde_json::Map::default(),
656        retry_attempts: 0,
657    };
658    let mut ops = vec![ProjectionOp::Node(n)];
659    if let Some(mut m) = read_manifest_opt(paths)? {
660        // `node_count` is derived from the projection directories in
661        // `advance_applied_seq`, never incremented here — see the module note
662        // and issue `manifest-counter-desync`. This op only refreshes the run's
663        // last-activity timestamp.
664        m.updated_at = ev.ts;
665        ops.push(ProjectionOp::Manifest(m));
666    }
667    Ok(ops)
668}
669
670/// Rewire an existing node to a freshly re-spawned agent after an empty-handed
671/// `agent-died` bounded auto-retry (issue `autoretry-agent-died-worker`). The
672/// supervisor tore down the dead worker's stale worktree and `create.sh`'d a
673/// clean one at the run's source branch; this event carries the new spawn
674/// metadata (`branch`, `base_sha`, `worktree_path`, tmux identity, `agent_pid`)
675/// plus the audit fields (`attempt`, `reason`).
676///
677/// It updates the node in place: the new agent's coordinates replace the dead
678/// one's, `status` returns to `Pending`, `started_at` is re-stamped so the
679/// watchdog's spawn-grace window re-applies to the new agent, `last_report` is
680/// cleared, and `retry_attempts` is incremented — the DURABLE, restart-safe
681/// bound the watchdog checks before scheduling the next retry.
682///
683/// Guards, mirroring the other node reducers:
684/// - A missing node is a no-op (a retry event whose node was never created).
685/// - A TERMINAL node is never resurrected (a settled node is frozen): if a real
686///   `node.report` raced in and terminalized the node, the retry is a dead event.
687///   This keeps replay robust and preserves the terminal-state invariant.
688fn reduce_node_retry(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
689    let events_path = paths.events();
690    let node_id = require_envelope_node_id(&events_path, ev)?;
691    let mut n = match read_node_opt(paths, &node_id)? {
692        Some(n) => n,
693        None => return Ok(vec![]),
694    };
695    // Terminal-state guard: a settled node is frozen. A late `node.report` that
696    // beat this retry to the lock wins; the retry must not resurrect it.
697    if n.status.is_terminal() {
698        tracing::debug!(
699            target: "octl_core::reducer",
700            seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
701            "no-op: node.retry against terminal node"
702        );
703        return Ok(vec![]);
704    }
705    let d = &ev.data;
706    // Rewire to the new agent. Each field mirrors `reduce_node_created`'s parsing
707    // so the projection shape is identical to a fresh spawn.
708    n.branch = d.get("branch").and_then(Value::as_str).map(str::to_string);
709    n.base_sha = d
710        .get("base_sha")
711        .and_then(Value::as_str)
712        .filter(|s| !s.is_empty())
713        .map(str::to_string);
714    n.worktree_path = d
715        .get("worktree_path")
716        .and_then(Value::as_str)
717        .map(str::to_string);
718    n.tmux_window = d
719        .get("tmux_window")
720        .and_then(Value::as_str)
721        .map(str::to_string);
722    n.tmux_identity = tmux_identity_from_data(d);
723    n.agent_pid = optional_i32(d, "agent_pid", &events_path, ev)?;
724    n.agent_pid_start_time = optional_ts(d, "agent_pid_start_time", &events_path, ev)?;
725    n.status = Status::Pending;
726    n.started_at = Some(ev.ts);
727    n.updated_at = ev.ts;
728    n.last_report = None;
729    // The event carries its ABSOLUTE attempt number (the supervisor set it to
730    // `retry_attempts + 1` at emit time). Assign it directly rather than a blind
731    // `+= 1`: this makes the projection a pure function of the event, so a
732    // full replay from seq 0, or a (guarded-against but defensive) double-apply,
733    // converges to the same `retry_attempts` the log declares — the audit count
734    // and the durable bound can never disagree. A legacy/malformed event with no
735    // parseable `attempt` falls back to the monotone increment.
736    n.retry_attempts = d
737        .get("attempt")
738        .and_then(Value::as_u64)
739        .map_or_else(|| n.retry_attempts.saturating_add(1), |a| a as u32);
740    Ok(vec![ProjectionOp::Node(n)])
741}
742
743fn reduce_node_status(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
744    let events_path = paths.events();
745    let node_id = require_envelope_node_id(&events_path, ev)?;
746    let mut n = match read_node_opt(paths, &node_id)? {
747        Some(n) => n,
748        None => return Ok(vec![]),
749    };
750    let new_status = require_status(ev, events_path)?;
751    // Terminal-state guard: a settled node never transitions again. See
752    // run-cli-read/handoff.md D5.
753    if n.status.is_terminal() {
754        trace_terminal_noop(ev, n.status, new_status);
755        return Ok(vec![]);
756    }
757    if n.status == new_status {
758        return Ok(vec![]);
759    }
760    n.status = new_status;
761    n.updated_at = ev.ts;
762    Ok(vec![ProjectionOp::Node(n)])
763}
764
765fn reduce_node_report(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
766    let events_path = paths.events();
767    let node_id = require_envelope_node_id(&events_path, ev)?;
768    let mut n = match read_node_opt(paths, &node_id)? {
769        Some(n) => n,
770        None => return Ok(vec![]),
771    };
772    // Terminal-state guard *before* payload validation: a node that already
773    // reached a terminal state is settled, so a late-arriving report (e.g. an
774    // agent success racing a `run cancel`) is a dead event — it must not
775    // resurrect the node, and must not even decorate the projection, so
776    // `last_report` is left untouched. Guarding first also keeps replay
777    // robust: a malformed dead report against a settled node is a clean
778    // no-op rather than a `CorruptEventLog` that would brick rebuild of a
779    // log `append_and_apply_event` already committed. See run-cli-read/handoff.md
780    // D5. (3/4 of /llm-review preferred guard-before-validate over the
781    // reverse the issue spec sketched; the required CorruptEventLog cases
782    // all target live nodes, so validation still runs for them.)
783    if n.status.is_terminal() {
784        // ONE exception to the dead-event rule: a late, CONFIRMED explicit-merge
785        // report is adopted even against a terminal node (issue
786        // `reducer-adopt-explicit-merge`). A watchdog `agent-died` false positive
787        // on a long-lived interactive run can terminalize a node BEFORE the user's
788        // `run merge` report arrives; an explicit user merge carries strictly
789        // higher-fidelity ground truth (the branch demonstrably landed in source)
790        // than a watchdog timeout, so it wins. Overwriting `last_report` here is
791        // what lets `any_node_merged_explicitly` see the merge and the SUPERVISOR
792        // — invariant #5's canonical teardown actor — warrant teardown, instead of
793        // the CLI compensating inline (issues `merge-skips-teardown`,
794        // `agent-died-merge-no-teardown-interactive`).
795        //
796        // Scoped tightly, on BOTH sides:
797        //   - incoming: a CONFIRMED SUCCESSFUL explicit merge
798        //     (`via == "explicit-merge"`, `success == true`, not `cancelled`) —
799        //     matches exactly the force-`-D` teardown gate (`node_branch_merged`),
800        //     so a failed/cancelled or non-merge late report never resurrects a
801        //     settled node and unmerged-work preservation is untouched.
802        //   - prior: only a `Failed` or `Done` node (positive whitelist). A
803        //     `Cancelled` terminal is a DELIBERATE `run cancel` teardown, not a
804        //     watchdog false positive, so a later merge does not override it (it
805        //     stays cancelled — matching the existing "late success report keeps the
806        //     cancel" reducer contract). The whitelist (rather than `!= Cancelled`)
807        //     is future-safe: a new deliberate-teardown terminal added later is not
808        //     silently resurrected to Done.
809        // Idempotent: if this exact report is already the node's `last_report`,
810        // re-folding it on replay is a clean no-op (never churns `updated_at`).
811        //
812        // NOTE — the RUN manifest is intentionally NOT reconciled here (it may stay
813        // `Failed` if a supervisor already rolled it up from the watchdog terminal).
814        // That is the pre-existing `false-failed-after-merge` symptom, NOT introduced
815        // by this change (the prior inline reclaim left the manifest `Failed` too):
816        // a run whose manifest was still non-terminal at adoption time DOES roll up
817        // to `Done` (the reattached supervisor's rollup sees the node `Done`); only
818        // an ALREADY-rolled-up terminal manifest stays put, because reconciling a
819        // settled run status is a distinct change to the run-status terminal guard,
820        // deliberately out of scope. Teardown fires either way (gated on
821        // `manifest.status.is_terminal()` + the merge marker), so no resource leaks.
822        if matches!(n.status, Status::Failed | Status::Done)
823            && report_is_confirmed_explicit_merge(&ev.data)
824        {
825            if n.last_report.as_ref() == Some(&ev.data) && n.status == Status::Done {
826                return Ok(vec![]);
827            }
828            tracing::info!(
829                target: "octl_core::reducer",
830                seq = ev.seq, kind = %ev.kind, node_id = %node_id, prior = ?n.status,
831                "adopting late explicit-merge report against terminal node (invariant #5 teardown)"
832            );
833            n.last_report = Some(ev.data.clone());
834            // A confirmed merge is a terminal SUCCESS: the work landed in source.
835            // (A false watchdog `Failed` is corrected to `Done`; a genuine `Done`
836            // stays `Done` with the merge marker adopted so teardown is warranted.)
837            n.status = Status::Done;
838            n.updated_at = ev.ts;
839            return Ok(vec![ProjectionOp::Node(n)]);
840        }
841        tracing::debug!(
842            target: "octl_core::reducer",
843            seq = ev.seq, kind = %ev.kind, node_id = %node_id, current = ?n.status,
844            "no-op: node.report against terminal node"
845        );
846        return Ok(vec![]);
847    }
848    // Live node: validate the report's terminal outcome. A `node.report`
849    // must express exactly one terminal outcome — success/failure XOR
850    // cancellation. Anything else (a bare `{}` with neither, or the
851    // contradiction `success: true` + `cancelled: true`) is a corrupt event:
852    // the reducer is the canonical gate, so reject it rather than silently
853    // leaving the node in a dangling state. See design.md §7.7 and
854    // node-cli-read/handoff.md D4.
855    let new_status = report_terminal_status(&events_path, ev)?;
856    n.last_report = Some(ev.data.clone());
857    n.status = new_status;
858    n.updated_at = ev.ts;
859    Ok(vec![ProjectionOp::Node(n)])
860}
861
862/// Emit an observability trace for a status event dropped by the terminal
863/// guard. Re-applying the *same* terminal status is routine idempotent replay
864/// (`debug`); an event carrying a *different* status is a real conflict that
865/// should not occur on a well-formed log (`warn`) — e.g. a `done` node being
866/// told to go `cancelled`. The guard no-ops either way; the level is the only
867/// difference, so a genuine corruption signal is visible without flooding
868/// logs on every replay.
869fn trace_terminal_noop(ev: &Event, current: Status, incoming: Status) {
870    if current == incoming {
871        tracing::debug!(
872            target: "octl_core::reducer",
873            seq = ev.seq, kind = %ev.kind, status = ?current,
874            "no-op: status re-applied to terminal target"
875        );
876    } else {
877        tracing::warn!(
878            target: "octl_core::reducer",
879            seq = ev.seq, kind = %ev.kind, current = ?current, incoming = ?incoming,
880            "no-op: ignored conflicting transition from terminal target"
881        );
882    }
883}
884
885/// The `via` marker `run merge` stamps on the terminal `node.report` it appends
886/// after a clean merge. This is the octl-cli/octl-core contract point: the CLI
887/// (`crates/octl-cli/src/run/merge.rs`) writes it and the reducer reads it here
888/// to decide adoption. Kept in core so the reducer's adoption gate and the
889/// supervisor's teardown gate (`supervise/cleanup.rs`) agree on the exact string.
890pub const VIA_EXPLICIT_MERGE: &str = "explicit-merge";
891
892/// True when a `node.report` payload is a CONFIRMED, SUCCESSFUL explicit merge —
893/// `via == "explicit-merge"`, `success` is the JSON boolean `true`, and
894/// `cancelled` is absent/null/`false`. This is the sole payload shape the
895/// terminal-node guard in [`reduce_node_report`] adopts, and it mirrors the
896/// supervisor's force-`-D` teardown gate (`node_branch_merged`) so a report
897/// carrying the merge marker but `success: false` (malformed/spoofed) never earns
898/// adoption or, downstream, a force delete.
899///
900/// Boolean typing is STRICT — matching the live-node path's `optional_bool`
901/// contract (`report_terminal_status`): a non-boolean `success` or `cancelled`
902/// (e.g. `"true"`, `"yes"`) makes this return `false` (not adoptable), so a
903/// malformed payload that a live node would reject as `CorruptEventLog` can never
904/// sneak an adoption in through this terminal-only exception. It returns `false`
905/// rather than erroring so a replay of such a dead event stays a clean no-op.
906fn report_is_confirmed_explicit_merge(data: &Value) -> bool {
907    let via = data.get("via").and_then(Value::as_str) == Some(VIA_EXPLICIT_MERGE);
908    let success = matches!(data.get("success"), Some(Value::Bool(true)));
909    let not_cancelled = matches!(
910        data.get("cancelled"),
911        None | Some(Value::Null | Value::Bool(false))
912    );
913    via && success && not_cancelled
914}
915
916/// Derive the terminal status a `node.report` event asserts, enforcing the
917/// success-XOR-cancelled invariant with strict boolean typing.
918///
919/// `cancelled: true` (with `success: false` or absent) → [`Status::Cancelled`].
920/// Otherwise `success` must be present: `true` → [`Status::Done`], `false` →
921/// [`Status::Failed`]. Neither field (bare `{}`), the contradiction
922/// `success: true` + `cancelled: true`, or a non-boolean `success` /
923/// `cancelled` is a [`Error::CorruptEventLog`].
924fn report_terminal_status(events_path: &Path, ev: &Event) -> Result<Status> {
925    let corrupt = |reason: String| Error::CorruptEventLog {
926        path: events_path.to_path_buf(),
927        reason,
928    };
929    let cancelled = optional_bool(events_path, ev, &ev.data, "cancelled")?.unwrap_or(false);
930    let success = optional_bool(events_path, ev, &ev.data, "success")?;
931    if cancelled {
932        if success == Some(true) {
933            return Err(corrupt(format!(
934                "event seq={} kind=node.report has contradictory `success: true` with `cancelled: true`",
935                ev.seq
936            )));
937        }
938        Ok(Status::Cancelled)
939    } else {
940        match success {
941            Some(true) => Ok(Status::Done),
942            Some(false) => Ok(Status::Failed),
943            None => Err(corrupt(format!(
944                "event seq={} kind=node.report must set boolean `success` or `cancelled: true`",
945                ev.seq
946            ))),
947        }
948    }
949}
950
951fn reduce_discussion_opened(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
952    let events_path = paths.events();
953    let d = &ev.data;
954    let discussion_id = DiscussionId::parse_str(want_str(&events_path, ev, d, "discussion_id")?)
955        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
956    if read_discussion_opt(paths, &discussion_id)?.is_some() {
957        return Ok(vec![]);
958    }
959    let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
960    let options = d
961        .get("options")
962        .and_then(Value::as_array)
963        .map(|a| {
964            a.iter()
965                .filter_map(|v| v.as_str().map(str::to_string))
966                .collect()
967        })
968        .unwrap_or_default();
969    let disc = Discussion {
970        schema_version: STATE_SCHEMA_VERSION,
971        discussion_id,
972        run_id: paths.run_id.clone(),
973        node_id,
974        opened_at: ev.ts,
975        severity: d
976            .get("severity")
977            .and_then(Value::as_str)
978            .unwrap_or("discuss")
979            .to_string(),
980        topic: want_str(&events_path, ev, d, "topic")?.to_string(),
981        context: d.get("context").and_then(Value::as_str).map(str::to_string),
982        options,
983        status: DiscussionStatus::Open,
984        resolution: None,
985        note: None,
986        resolved_at: None,
987    };
988    let mut ops = vec![ProjectionOp::Discussion(disc)];
989    if let Some(mut m) = read_manifest_opt(paths)? {
990        // `open_discussions` is derived in `advance_applied_seq`, not bumped
991        // here — see the module note. Only the timestamp is refreshed.
992        m.updated_at = ev.ts;
993        ops.push(ProjectionOp::Manifest(m));
994    }
995    Ok(ops)
996}
997
998fn reduce_discussion_resolved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
999    let events_path = paths.events();
1000    let id = DiscussionId::parse_str(want_str(&events_path, ev, &ev.data, "discussion_id")?)
1001        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1002    let mut disc = match read_discussion_opt(paths, &id)? {
1003        Some(d) => d,
1004        None => return Ok(vec![]),
1005    };
1006    if matches!(disc.status, DiscussionStatus::Resolved) {
1007        return Ok(vec![]);
1008    }
1009    disc.status = DiscussionStatus::Resolved;
1010    // `discussion.resolved` must carry a string `resolution` — without
1011    // one, the projection would advance to `Resolved` with `resolution:
1012    // null`, which is a corrupt domain state. Reject at the reducer
1013    // boundary so any writer (CLI, future supervisor, manual `event
1014    // create`) is held to the same contract.
1015    disc.resolution = Some(want_str(&events_path, ev, &ev.data, "resolution")?.to_string());
1016    disc.note = optional_str(&events_path, ev, &ev.data, "note")?;
1017    disc.resolved_at = Some(ev.ts);
1018    let mut ops = vec![ProjectionOp::Discussion(disc)];
1019    if let Some(mut m) = read_manifest_opt(paths)? {
1020        // `open_discussions` is derived in `advance_applied_seq`, not
1021        // decremented here — see the module note. The old `saturating_sub`
1022        // could strand a too-high count if this resolve's manifest write was
1023        // lost to a crash and the replay then short-circuited on the
1024        // already-`Resolved` discussion. Only the timestamp is refreshed.
1025        m.updated_at = ev.ts;
1026        ops.push(ProjectionOp::Manifest(m));
1027    }
1028    Ok(ops)
1029}
1030
1031fn reduce_spinoff_proposed(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1032    let events_path = paths.events();
1033    let d = &ev.data;
1034    let proposal_id = ProposalId::parse_str(want_str(&events_path, ev, d, "proposal_id")?)
1035        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1036    if read_spinoff_opt(paths, &proposal_id)?.is_some() {
1037        return Ok(vec![]);
1038    }
1039    let proposed_kind =
1040        data_kind(d.get("proposed_kind").unwrap_or(&Value::Null)).ok_or_else(|| {
1041            Error::CorruptEventLog {
1042                path: events_path.clone(),
1043                reason: format!(
1044                    "event seq={} kind=spinoff.proposed missing/invalid `proposed_kind`",
1045                    ev.seq
1046                ),
1047            }
1048        })?;
1049    let node_id = want_node_id_with_fallback(&events_path, ev, d, "node_id")?;
1050    let s = SpinoffProposal {
1051        schema_version: STATE_SCHEMA_VERSION,
1052        proposal_id,
1053        run_id: paths.run_id.clone(),
1054        node_id,
1055        proposed_at: ev.ts,
1056        proposed_title: want_str(&events_path, ev, d, "proposed_title")?.to_string(),
1057        proposed_kind,
1058        rationale: d
1059            .get("rationale")
1060            .and_then(Value::as_str)
1061            .map(str::to_string),
1062        status: SpinoffStatus::Proposed,
1063        accepted_as_issue_slug: None,
1064        rejected_reason: None,
1065        resolved_at: None,
1066    };
1067    let mut ops = vec![ProjectionOp::Spinoff(s)];
1068    if let Some(mut m) = read_manifest_opt(paths)? {
1069        // `pending_spinoffs` is derived in `advance_applied_seq`, not bumped
1070        // here — see the module note. Only the timestamp is refreshed.
1071        m.updated_at = ev.ts;
1072        ops.push(ProjectionOp::Manifest(m));
1073    }
1074    Ok(ops)
1075}
1076
1077fn reduce_spinoff_approved(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1078    let events_path = paths.events();
1079    let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1080        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1081    let mut s = match read_spinoff_opt(paths, &id)? {
1082        Some(s) => s,
1083        None => return Ok(vec![]),
1084    };
1085    if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1086        return Ok(vec![]);
1087    }
1088    s.status = SpinoffStatus::Approved;
1089    s.accepted_as_issue_slug = ev
1090        .data
1091        .get("issue_slug")
1092        .and_then(Value::as_str)
1093        .map(str::to_string);
1094    s.resolved_at = Some(ev.ts);
1095    let mut ops = vec![ProjectionOp::Spinoff(s)];
1096    if let Some(mut m) = read_manifest_opt(paths)? {
1097        // `pending_spinoffs` is derived in `advance_applied_seq`, not
1098        // decremented here — see the module note. The old `saturating_sub`
1099        // could strand a too-high count if this resolution's manifest write was
1100        // lost to a crash and the replay then short-circuited on the
1101        // already-settled proposal. Only the timestamp is refreshed.
1102        m.updated_at = ev.ts;
1103        ops.push(ProjectionOp::Manifest(m));
1104    }
1105    Ok(ops)
1106}
1107
1108fn reduce_spinoff_rejected(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1109    let events_path = paths.events();
1110    let id = ProposalId::parse_str(want_str(&events_path, ev, &ev.data, "proposal_id")?)
1111        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1112    let mut s = match read_spinoff_opt(paths, &id)? {
1113        Some(s) => s,
1114        None => return Ok(vec![]),
1115    };
1116    if matches!(s.status, SpinoffStatus::Approved | SpinoffStatus::Rejected) {
1117        return Ok(vec![]);
1118    }
1119    s.status = SpinoffStatus::Rejected;
1120    s.rejected_reason = ev
1121        .data
1122        .get("reason")
1123        .and_then(Value::as_str)
1124        .map(str::to_string);
1125    s.resolved_at = Some(ev.ts);
1126    let mut ops = vec![ProjectionOp::Spinoff(s)];
1127    if let Some(mut m) = read_manifest_opt(paths)? {
1128        // `pending_spinoffs` is derived in `advance_applied_seq`, not
1129        // decremented here — see the module note. The old `saturating_sub`
1130        // could strand a too-high count if this resolution's manifest write was
1131        // lost to a crash and the replay then short-circuited on the
1132        // already-settled proposal. Only the timestamp is refreshed.
1133        m.updated_at = ev.ts;
1134        ops.push(ProjectionOp::Manifest(m));
1135    }
1136    Ok(ops)
1137}
1138
1139fn reduce_child_spawned(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1140    // `child.spawned` is written to the PARENT run's events; the parent
1141    // spawning node is `ev.node_id`, the child run/node lives in `data`.
1142    let events_path = paths.events();
1143    let parent_node_id = ev.node_id.clone().ok_or_else(|| Error::CorruptEventLog {
1144        path: events_path.clone(),
1145        reason: format!(
1146            "event seq={} kind=child.spawned missing parent `node_id`",
1147            ev.seq
1148        ),
1149    })?;
1150    let child_run_id = RunId::parse_str(want_str(&events_path, ev, &ev.data, "child_run_id")?)
1151        .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1152    let child_node_id = NodeId::parse_str(
1153        ev.data
1154            .get("child_node_id")
1155            .and_then(Value::as_str)
1156            .unwrap_or("n-0001"),
1157    )
1158    .map_err(|e| corrupt_id(&events_path, ev, &e))?;
1159    let mut n = match read_node_opt(paths, &parent_node_id)? {
1160        Some(n) => n,
1161        None => return Ok(vec![]),
1162    };
1163    let new_ref = ChildRef {
1164        run_id: child_run_id,
1165        node_id: child_node_id,
1166    };
1167    if n.children.iter().any(|c| c == &new_ref) {
1168        // Already recorded — pure no-op so replayed events don't churn
1169        // `updated_at` or the projection file.
1170        return Ok(vec![]);
1171    }
1172    n.children.push(new_ref);
1173    n.updated_at = ev.ts;
1174    Ok(vec![ProjectionOp::Node(n)])
1175}
1176
1177/// `supervisor.attached` records the supervisor PID watching the envelope
1178/// node onto `Node.supervisor_pid`. Event-sourced replacement for the
1179/// supervisor's former direct `write_node` (issue
1180/// `supervisor-state-not-event-sourced`), so a from-scratch projection
1181/// rebuild reproduces the field.
1182///
1183/// Latest-wins: a later attach (a supervisor restart binds a fresh PID)
1184/// overrides the recorded value. Re-applying an event that carries the
1185/// already-recorded PID is a pure no-op, so replay never churns the
1186/// projection file's `updated_at`.
1187fn reduce_supervisor_attached(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1188    let events_path = paths.events();
1189    let node_id = require_envelope_node_id(&events_path, ev)?;
1190    let raw = ev
1191        .data
1192        .get("pid")
1193        .and_then(Value::as_i64)
1194        .ok_or_else(|| Error::CorruptEventLog {
1195            path: events_path.clone(),
1196            reason: format!(
1197                "event seq={} kind=supervisor.attached missing/invalid `pid`",
1198                ev.seq
1199            ),
1200        })?;
1201    let pid = i32::try_from(raw).map_err(|_| Error::CorruptEventLog {
1202        path: events_path.clone(),
1203        reason: format!(
1204            "event seq={} kind=supervisor.attached `pid` out of i32 range: {raw}",
1205            ev.seq
1206        ),
1207    })?;
1208    let mut n = match read_node_opt(paths, &node_id)? {
1209        Some(n) => n,
1210        None => return Ok(vec![]),
1211    };
1212    if n.supervisor_pid == Some(pid) {
1213        return Ok(vec![]);
1214    }
1215    n.supervisor_pid = Some(pid);
1216    n.updated_at = ev.ts;
1217    Ok(vec![ProjectionOp::Node(n)])
1218}
1219
1220/// `supervisor.cursor_advanced` mirrors the supervisor's per-child report
1221/// cursor onto the envelope (parent) node's `last_processed_report_seq_by_child`
1222/// map. Event-sourced replacement for the supervisor's former direct
1223/// `write_node` of that map (issue `supervisor-state-not-event-sourced`).
1224///
1225/// The cursor is monotonic: a `report_seq` at or below the recorded
1226/// high-water mark for this child is a no-op, so replaying the same event —
1227/// or an older out-of-order one — never moves the cursor backward or churns
1228/// the projection. This is the §7.3 idempotency guarantee at the reducer
1229/// boundary.
1230fn reduce_supervisor_cursor_advanced(paths: &RunPaths, ev: &Event) -> Result<Vec<ProjectionOp>> {
1231    let events_path = paths.events();
1232    let node_id = require_envelope_node_id(&events_path, ev)?;
1233    let child_run_id = want_str(&events_path, ev, &ev.data, "child_run_id")?;
1234    // Validate the child id even though it only becomes a map key — a forged
1235    // event must not smuggle a path-shaped or malformed run id into the
1236    // projection.
1237    RunId::parse_str(child_run_id).map_err(|e| corrupt_id(&events_path, ev, &e))?;
1238    let report_seq = ev
1239        .data
1240        .get("report_seq")
1241        .and_then(Value::as_u64)
1242        .ok_or_else(|| Error::CorruptEventLog {
1243            path: events_path.clone(),
1244            reason: format!(
1245                "event seq={} kind=supervisor.cursor_advanced missing/invalid `report_seq`",
1246                ev.seq
1247            ),
1248        })?;
1249    let mut n = match read_node_opt(paths, &node_id)? {
1250        Some(n) => n,
1251        None => return Ok(vec![]),
1252    };
1253    if let Some(prev) = n
1254        .last_processed_report_seq_by_child
1255        .get(child_run_id)
1256        .and_then(Value::as_u64)
1257    {
1258        if report_seq <= prev {
1259            return Ok(vec![]);
1260        }
1261    }
1262    n.last_processed_report_seq_by_child
1263        .insert(child_run_id.to_string(), Value::from(report_seq));
1264    n.updated_at = ev.ts;
1265    Ok(vec![ProjectionOp::Node(n)])
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271    use crate::schema::Event;
1272    use chrono::Utc;
1273    use tempfile::TempDir;
1274
1275    fn event(run_id: &str) -> Event {
1276        Event {
1277            ts: Utc::now(),
1278            seq: 1,
1279            kind: "run.status".into(),
1280            run_id: RunId::parse_str(run_id).unwrap(),
1281            node_id: None,
1282            idempotency_key: None,
1283            data: serde_json::json!({ "status": "running" }),
1284        }
1285    }
1286
1287    #[test]
1288    fn orchestrator_decision_and_discuss_critical_reduce_to_noop() {
1289        // The /orchestrate audit kinds are append-only: the reducer must plan
1290        // ZERO projection ops for them regardless of payload, so the event log
1291        // is their sole home and no projection is created or mutated.
1292        let tmp = TempDir::new().unwrap();
1293        let run_id = "01jxsnap000000000000000000";
1294        let rid = RunId::parse_str(run_id).unwrap();
1295        let dir = crate::run_dir(tmp.path(), &rid);
1296        std::fs::create_dir_all(&dir).unwrap();
1297        let paths = RunPaths::new(dir, run_id).unwrap();
1298
1299        // Bootstrap a manifest so we can prove the audit events leave it
1300        // byte-for-byte untouched (no counter churn, no status drift).
1301        let mut created = event(run_id);
1302        created.kind = "run.created".into();
1303        created.data = serde_json::json!({
1304            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1305        });
1306        apply_event(&paths, &created).expect("run.created applies");
1307        let manifest_before = std::fs::read(paths.manifest()).unwrap();
1308
1309        for (seq, kind) in [(10u64, "orchestrator.decision"), (11, "discuss.critical")] {
1310            let mut ev = event(run_id);
1311            ev.seq = seq;
1312            ev.kind = kind.into();
1313            // A non-trivial payload to prove the reducer ignores it wholesale.
1314            ev.data = serde_json::json!({ "summary": "x", "arbitrary": [1, 2, 3] });
1315            let ops = reduce_event_to_ops(&paths, &ev).expect("audit kind reduces cleanly");
1316            assert!(ops.is_empty(), "{kind} must plan no projection ops");
1317            // apply_event is the plan+commit path; it must also be a clean no-op.
1318            apply_event(&paths, &ev).expect("audit kind applies as no-op");
1319        }
1320
1321        // The manifest is unchanged and no stray projection dirs appeared.
1322        assert_eq!(
1323            std::fs::read(paths.manifest()).unwrap(),
1324            manifest_before,
1325            "audit events must not mutate the manifest"
1326        );
1327        assert!(!paths.nodes_dir().exists(), "no node projection created");
1328    }
1329
1330    #[test]
1331    fn run_created_folds_harness_when_present_and_defaults_none() {
1332        let tmp = TempDir::new().unwrap();
1333
1334        // A `run.created` carrying `harness` folds it onto the manifest.
1335        let run_id = "01jxhrnsaa0000000000000001";
1336        let rid = RunId::parse_str(run_id).unwrap();
1337        let dir = crate::run_dir(tmp.path(), &rid);
1338        std::fs::create_dir_all(&dir).unwrap();
1339        let paths = RunPaths::new(dir, run_id).unwrap();
1340        let mut created = event(run_id);
1341        created.kind = "run.created".into();
1342        created.data = serde_json::json!({
1343            "kind": "spinoff", "lifecycle": "autonomous", "title": "t",
1344            "harness": "pi", "harness_source": "flag",
1345        });
1346        apply_event(&paths, &created).expect("run.created applies");
1347        let m = read_manifest_opt(&paths).unwrap().unwrap();
1348        assert_eq!(m.harness.as_deref(), Some("pi"));
1349
1350        // A `run.created` WITHOUT `harness` (legacy / claude) leaves it `None`.
1351        let run_id2 = "01jxhrnsaa0000000000000002";
1352        let rid2 = RunId::parse_str(run_id2).unwrap();
1353        let dir2 = crate::run_dir(tmp.path(), &rid2);
1354        std::fs::create_dir_all(&dir2).unwrap();
1355        let paths2 = RunPaths::new(dir2, run_id2).unwrap();
1356        let mut created2 = event(run_id2);
1357        created2.kind = "run.created".into();
1358        created2.data =
1359            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1360        apply_event(&paths2, &created2).expect("run.created applies");
1361        let m2 = read_manifest_opt(&paths2).unwrap().unwrap();
1362        assert_eq!(m2.harness, None);
1363    }
1364
1365    /// Bootstrap a run manifest + one live `n-0001` spinoff node, returning its
1366    /// paths. Used by the `node.retry` reducer tests.
1367    fn bootstrap_retry_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1368        let rid = RunId::parse_str(run_id).unwrap();
1369        let dir = crate::run_dir(tmp.path(), &rid);
1370        std::fs::create_dir_all(&dir).unwrap();
1371        let paths = RunPaths::new(dir, run_id).unwrap();
1372        let mut created = event(run_id);
1373        created.kind = "run.created".into();
1374        created.data =
1375            serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" });
1376        apply_event(&paths, &created).expect("run.created applies");
1377        let mut node = event(run_id);
1378        node.seq = 2;
1379        node.kind = "node.created".into();
1380        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1381        node.data = serde_json::json!({
1382            "kind": "spinoff",
1383            "branch": "wt/foo",
1384            "worktree_path": "/tmp/old-wt",
1385            "agent_pid": 111,
1386        });
1387        apply_event(&paths, &node).expect("node.created applies");
1388        paths
1389    }
1390
1391    /// `node.retry` rewires the node to the freshly re-spawned agent, returns it to
1392    /// `Pending`, re-stamps `started_at`, and increments the durable
1393    /// `retry_attempts` bound (issue `autoretry-agent-died-worker`).
1394    #[test]
1395    fn node_retry_rewires_node_and_increments_attempts() {
1396        let tmp = TempDir::new().unwrap();
1397        let run_id = "01jxsnap000000000000000000";
1398        let paths = bootstrap_retry_node(&tmp, run_id);
1399
1400        let mut retry = event(run_id);
1401        retry.seq = 3;
1402        retry.kind = "node.retry".into();
1403        retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1404        retry.data = serde_json::json!({
1405            "attempt": 1,
1406            "reason": "agent-died",
1407            "branch": "wt/foo-r1",
1408            "base_sha": "a".repeat(40),
1409            "worktree_path": "/tmp/new-wt",
1410            "agent_pid": 222,
1411            "tmux_session": "s",
1412            "tmux_window_id": "@9",
1413        });
1414        apply_event(&paths, &retry).expect("node.retry applies");
1415
1416        let n = read_n0001(&paths);
1417        assert_eq!(n.retry_attempts, 1, "attempt bound incremented");
1418        assert_eq!(
1419            n.branch.as_deref(),
1420            Some("wt/foo-r1"),
1421            "rewired to new branch"
1422        );
1423        assert_eq!(n.worktree_path.as_deref(), Some("/tmp/new-wt"));
1424        assert_eq!(n.agent_pid, Some(222), "rewired to new agent pid");
1425        assert_eq!(n.status, Status::Pending, "node returns to pending");
1426        assert!(n.last_report.is_none());
1427        assert_eq!(
1428            n.tmux_identity.as_ref().map(|t| t.window_id.as_str()),
1429            Some("@9"),
1430            "rewired tmux identity"
1431        );
1432
1433        // A second retry increments again — the bound is monotone.
1434        let mut retry2 = event(run_id);
1435        retry2.seq = 4;
1436        retry2.kind = "node.retry".into();
1437        retry2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1438        retry2.data = serde_json::json!({
1439            "attempt": 2, "reason": "agent-died", "branch": "wt/foo-r2",
1440            "worktree_path": "/tmp/new-wt-2", "agent_pid": 333,
1441        });
1442        apply_event(&paths, &retry2).expect("node.retry applies");
1443        assert_eq!(read_n0001(&paths).retry_attempts, 2);
1444    }
1445
1446    /// A `node.retry` against an already-terminal node is a dead event: the
1447    /// terminal-state invariant holds, so a late retry never resurrects a settled
1448    /// node (a real report that raced in wins).
1449    #[test]
1450    fn node_retry_against_terminal_node_is_noop() {
1451        let tmp = TempDir::new().unwrap();
1452        let run_id = "01jxsnap000000000000000000";
1453        let paths = bootstrap_retry_node(&tmp, run_id);
1454
1455        // Terminalize the node via a success report.
1456        let mut report = event(run_id);
1457        report.seq = 3;
1458        report.kind = "node.report".into();
1459        report.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1460        report.data = serde_json::json!({ "success": true });
1461        apply_event(&paths, &report).expect("node.report applies");
1462        assert_eq!(read_n0001(&paths).status, Status::Done);
1463
1464        let mut retry = event(run_id);
1465        retry.seq = 4;
1466        retry.kind = "node.retry".into();
1467        retry.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1468        retry.data = serde_json::json!({
1469            "attempt": 1, "reason": "agent-died", "branch": "wt/foo-r1",
1470            "worktree_path": "/tmp/new-wt", "agent_pid": 222,
1471        });
1472        apply_event(&paths, &retry).expect("node.retry applies as no-op");
1473
1474        let n = read_n0001(&paths);
1475        assert_eq!(n.status, Status::Done, "terminal node not resurrected");
1476        assert_eq!(n.retry_attempts, 0, "no increment against terminal node");
1477        assert_eq!(n.agent_pid, Some(111), "not rewired");
1478    }
1479
1480    #[test]
1481    fn apply_event_rejects_event_from_a_different_run() {
1482        let tmp = TempDir::new().unwrap();
1483        let run_id = "01jxsnap000000000000000000";
1484        let rid = RunId::parse_str(run_id).unwrap();
1485        let dir = crate::run_dir(tmp.path(), &rid);
1486        std::fs::create_dir_all(&dir).unwrap();
1487        let paths = RunPaths::new(dir, run_id).unwrap();
1488
1489        // An event whose envelope names a different run must not be folded.
1490        let foreign = event("02jxsnap000000000000000000");
1491        let err = apply_event(&paths, &foreign).expect_err("cross-run event must be rejected");
1492        assert!(matches!(err, Error::CorruptEventLog { .. }), "got {err:?}");
1493
1494        // The matching run_id is accepted (no projection exists yet, so
1495        // `run.status` is a clean no-op rather than an error).
1496        let mine = event(run_id);
1497        apply_event(&paths, &mine).expect("matching run_id must be accepted");
1498    }
1499
1500    #[test]
1501    fn tmux_identity_from_data_reads_qualified_fields() {
1502        let d = serde_json::json!({
1503            "tmux_socket": "/private/tmp/tmux-501/default",
1504            "tmux_session": "octl",
1505            "tmux_window_id": "@42",
1506        });
1507        let id = tmux_identity_from_data(&d).expect("qualified identity");
1508        assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
1509        assert_eq!(id.session, "octl");
1510        assert_eq!(id.window_id, "@42");
1511        // No pane_id in this event → None (back-compat / older create.sh).
1512        assert_eq!(id.pane_id, None);
1513
1514        // Null socket is tolerated — session + window_id are the minimum.
1515        let d2 = serde_json::json!({
1516            "tmux_socket": null,
1517            "tmux_session": "octl",
1518            "tmux_window_id": "@7",
1519        });
1520        let id2 = tmux_identity_from_data(&d2).expect("identity without socket");
1521        assert_eq!(id2.socket, None);
1522        assert_eq!(id2.window_id, "@7");
1523
1524        // A create.sh that emits `tmux_pane_id` is folded into the identity.
1525        let d3 = serde_json::json!({
1526            "tmux_session": "octl",
1527            "tmux_window_id": "@42",
1528            "tmux_pane_id": "%7",
1529        });
1530        let id3 = tmux_identity_from_data(&d3).expect("identity with pane");
1531        assert_eq!(id3.pane_id.as_deref(), Some("%7"));
1532        assert_eq!(id3.capture_target(), "%7");
1533
1534        // Explicit `tmux_pane_id: null` (create.sh emits null when its pane
1535        // query failed) must fold to None — never `Some("null")`.
1536        let d4 = serde_json::json!({
1537            "tmux_session": "octl",
1538            "tmux_window_id": "@42",
1539            "tmux_pane_id": null,
1540        });
1541        let id4 = tmux_identity_from_data(&d4).expect("identity with null pane");
1542        assert_eq!(id4.pane_id, None);
1543        assert_eq!(id4.capture_target(), "@42");
1544    }
1545
1546    #[test]
1547    fn tmux_identity_from_data_back_compat_is_none() {
1548        // Legacy create.sh: no qualified fields at all.
1549        let legacy = serde_json::json!({ "tmux_window": "🚀 wt/x" });
1550        assert!(tmux_identity_from_data(&legacy).is_none());
1551        // Partial (window_id without session) is also insufficient → None.
1552        let partial = serde_json::json!({ "tmux_window_id": "@42" });
1553        assert!(tmux_identity_from_data(&partial).is_none());
1554    }
1555
1556    /// End-to-end: a `node.created` event carrying the qualified fields folds
1557    /// them into `Node.tmux_identity`; one without them leaves it `None`.
1558    #[test]
1559    fn node_created_populates_tmux_identity() {
1560        let tmp = TempDir::new().unwrap();
1561        let run_id = "01jxsnap000000000000000000";
1562        let rid = RunId::parse_str(run_id).unwrap();
1563        let dir = crate::run_dir(tmp.path(), &rid);
1564        std::fs::create_dir_all(&dir).unwrap();
1565        let paths = RunPaths::new(dir, run_id).unwrap();
1566
1567        let mut ev = event(run_id);
1568        ev.seq = 2;
1569        ev.kind = "node.created".into();
1570        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1571        ev.data = serde_json::json!({
1572            "kind": "spinoff",
1573            "tmux_window": "🚀 wt/x",
1574            "tmux_socket": "/private/tmp/tmux-501/default",
1575            "tmux_session": "octl",
1576            "tmux_window_id": "@42",
1577        });
1578        apply_event(&paths, &ev).expect("node.created applies");
1579        let n = read_node_opt(&paths, &NodeId::parse_str("n-0001").unwrap())
1580            .unwrap()
1581            .unwrap();
1582        let id = n.tmux_identity.expect("qualified identity recorded");
1583        assert_eq!(id.session, "octl");
1584        assert_eq!(id.window_id, "@42");
1585        assert_eq!(n.tmux_window.as_deref(), Some("🚀 wt/x"));
1586
1587        // A second run with a legacy event leaves tmux_identity None.
1588        let run2 = "02jxsnap000000000000000000";
1589        let rid2 = RunId::parse_str(run2).unwrap();
1590        let dir2 = crate::run_dir(tmp.path(), &rid2);
1591        std::fs::create_dir_all(&dir2).unwrap();
1592        let paths2 = RunPaths::new(dir2, run2).unwrap();
1593        let mut ev2 = event(run2);
1594        ev2.seq = 2;
1595        ev2.kind = "node.created".into();
1596        ev2.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1597        ev2.data = serde_json::json!({ "kind": "spinoff", "tmux_window": "🚀 wt/y" });
1598        apply_event(&paths2, &ev2).expect("legacy node.created applies");
1599        let n2 = read_node_opt(&paths2, &NodeId::parse_str("n-0001").unwrap())
1600            .unwrap()
1601            .unwrap();
1602        assert!(n2.tmux_identity.is_none());
1603        assert_eq!(n2.tmux_window.as_deref(), Some("🚀 wt/y"));
1604    }
1605
1606    /// Bootstrap a run with a single `n-0001` node via the event-sourced path,
1607    /// returning its paths. Shared by the supervisor-state replay tests below.
1608    fn seed_run_with_node(tmp: &TempDir, run_id: &str) -> RunPaths {
1609        let rid = RunId::parse_str(run_id).unwrap();
1610        let dir = crate::run_dir(tmp.path(), &rid);
1611        std::fs::create_dir_all(&dir).unwrap();
1612        let paths = RunPaths::new(dir, run_id).unwrap();
1613
1614        let mut created = event(run_id);
1615        created.kind = "run.created".into();
1616        created.data = serde_json::json!({
1617            "kind": "spinoff", "lifecycle": "autonomous", "title": "t"
1618        });
1619        apply_event(&paths, &created).expect("run.created applies");
1620
1621        let mut node = event(run_id);
1622        node.seq = 2;
1623        node.kind = "node.created".into();
1624        node.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1625        node.data = serde_json::json!({ "kind": "spinoff" });
1626        apply_event(&paths, &node).expect("node.created applies");
1627        paths
1628    }
1629
1630    fn read_n0001(paths: &RunPaths) -> Node {
1631        read_node_opt(paths, &NodeId::parse_str("n-0001").unwrap())
1632            .unwrap()
1633            .unwrap()
1634    }
1635
1636    /// Replaying `supervisor.attached` from scratch reproduces
1637    /// `Node.supervisor_pid` — the field is now event-sourced, not a
1638    /// projection-only write (issue `supervisor-state-not-event-sourced`).
1639    #[test]
1640    fn supervisor_attached_sets_supervisor_pid() {
1641        let tmp = TempDir::new().unwrap();
1642        let run_id = "01jxsnap000000000000000000";
1643        let paths = seed_run_with_node(&tmp, run_id);
1644        assert_eq!(read_n0001(&paths).supervisor_pid, None);
1645
1646        let mut ev = event(run_id);
1647        ev.seq = 3;
1648        ev.kind = "supervisor.attached".into();
1649        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1650        ev.data = serde_json::json!({ "pid": 47820 });
1651        apply_event(&paths, &ev).expect("supervisor.attached applies");
1652        assert_eq!(read_n0001(&paths).supervisor_pid, Some(47820));
1653    }
1654
1655    /// A second attach with a different pid overrides (latest-wins); a replay
1656    /// of the *same* pid is a pure no-op that does not churn `updated_at`.
1657    #[test]
1658    fn supervisor_attached_latest_wins_and_idempotent_on_replay() {
1659        let tmp = TempDir::new().unwrap();
1660        let run_id = "01jxsnap000000000000000000";
1661        let paths = seed_run_with_node(&tmp, run_id);
1662
1663        let mut ev = event(run_id);
1664        ev.kind = "supervisor.attached".into();
1665        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1666
1667        ev.seq = 3;
1668        ev.data = serde_json::json!({ "pid": 100 });
1669        apply_event(&paths, &ev).expect("first attach applies");
1670        assert_eq!(read_n0001(&paths).supervisor_pid, Some(100));
1671
1672        // A restart binds a fresh pid: latest-wins.
1673        ev.seq = 4;
1674        ev.data = serde_json::json!({ "pid": 200 });
1675        apply_event(&paths, &ev).expect("second attach applies");
1676        let after_second = read_n0001(&paths);
1677        assert_eq!(after_second.supervisor_pid, Some(200));
1678
1679        // Replaying the latest event again is a no-op: the planned ops are
1680        // empty and the projection bytes (including `updated_at`) are unchanged.
1681        let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1682        assert!(ops.is_empty(), "re-applying same pid must plan no ops");
1683        apply_event(&paths, &ev).expect("replay applies as no-op");
1684        assert_eq!(read_n0001(&paths).updated_at, after_second.updated_at);
1685    }
1686
1687    /// Replaying `supervisor.cursor_advanced` from scratch reproduces
1688    /// `Node.last_processed_report_seq_by_child`.
1689    #[test]
1690    fn supervisor_cursor_advanced_sets_report_cursor() {
1691        let tmp = TempDir::new().unwrap();
1692        let run_id = "01jxsnap000000000000000000";
1693        let paths = seed_run_with_node(&tmp, run_id);
1694        let child = "02jxsnap000000000000000000";
1695
1696        let mut ev = event(run_id);
1697        ev.seq = 3;
1698        ev.kind = "supervisor.cursor_advanced".into();
1699        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1700        ev.data = serde_json::json!({ "child_run_id": child, "report_seq": 7 });
1701        apply_event(&paths, &ev).expect("cursor_advanced applies");
1702
1703        let n = read_n0001(&paths);
1704        assert_eq!(
1705            n.last_processed_report_seq_by_child.get(child),
1706            Some(&Value::from(7u64))
1707        );
1708    }
1709
1710    /// The cursor is monotonic and idempotent: re-applying the same
1711    /// `(child_run_id, report_seq)` is a no-op, an older seq never moves the
1712    /// cursor backward, and a higher seq advances it. A second distinct child
1713    /// gets its own independent entry.
1714    #[test]
1715    fn supervisor_cursor_advanced_is_monotonic_and_idempotent() {
1716        let tmp = TempDir::new().unwrap();
1717        let run_id = "01jxsnap000000000000000000";
1718        let paths = seed_run_with_node(&tmp, run_id);
1719        let child_a = "02jxsnap000000000000000000";
1720        let child_b = "03jxsnap000000000000000000";
1721
1722        let mut ev = event(run_id);
1723        ev.kind = "supervisor.cursor_advanced".into();
1724        ev.node_id = Some(NodeId::parse_str("n-0001").unwrap());
1725
1726        ev.seq = 3;
1727        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 5 });
1728        apply_event(&paths, &ev).expect("seq 5 applies");
1729
1730        // Replay the exact same event — no-op, plans zero ops.
1731        let ops = reduce_event_to_ops(&paths, &ev).expect("replay reduces cleanly");
1732        assert!(ops.is_empty(), "re-applying same cursor must plan no ops");
1733
1734        // An older seq must not move the cursor backward.
1735        ev.seq = 4;
1736        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 3 });
1737        let ops = reduce_event_to_ops(&paths, &ev).expect("older seq reduces cleanly");
1738        assert!(ops.is_empty(), "older seq must plan no ops");
1739        apply_event(&paths, &ev).expect("older seq applies as no-op");
1740        assert_eq!(
1741            read_n0001(&paths)
1742                .last_processed_report_seq_by_child
1743                .get(child_a),
1744            Some(&Value::from(5u64))
1745        );
1746
1747        // A higher seq advances; an independent child gets its own entry.
1748        ev.seq = 5;
1749        ev.data = serde_json::json!({ "child_run_id": child_a, "report_seq": 9 });
1750        apply_event(&paths, &ev).expect("higher seq applies");
1751        ev.seq = 6;
1752        ev.data = serde_json::json!({ "child_run_id": child_b, "report_seq": 1 });
1753        apply_event(&paths, &ev).expect("second child applies");
1754
1755        let n = read_n0001(&paths);
1756        assert_eq!(
1757            n.last_processed_report_seq_by_child.get(child_a),
1758            Some(&Value::from(9u64))
1759        );
1760        assert_eq!(
1761            n.last_processed_report_seq_by_child.get(child_b),
1762            Some(&Value::from(1u64))
1763        );
1764    }
1765
1766    /// Both new kinds reject a malformed payload at the reducer boundary so a
1767    /// forged event can never write a corrupt projection.
1768    #[test]
1769    fn supervisor_state_events_reject_malformed_payloads() {
1770        let tmp = TempDir::new().unwrap();
1771        let run_id = "01jxsnap000000000000000000";
1772        let paths = seed_run_with_node(&tmp, run_id);
1773        let nid = Some(NodeId::parse_str("n-0001").unwrap());
1774
1775        // Missing pid.
1776        let mut ev = event(run_id);
1777        ev.seq = 3;
1778        ev.kind = "supervisor.attached".into();
1779        ev.node_id = nid.clone();
1780        ev.data = serde_json::json!({});
1781        assert!(matches!(
1782            reduce_event_to_ops(&paths, &ev),
1783            Err(Error::CorruptEventLog { .. })
1784        ));
1785
1786        // Missing envelope node_id.
1787        ev.node_id = None;
1788        ev.data = serde_json::json!({ "pid": 1 });
1789        assert!(matches!(
1790            reduce_event_to_ops(&paths, &ev),
1791            Err(Error::CorruptEventLog { .. })
1792        ));
1793
1794        // cursor_advanced: malformed child_run_id.
1795        let mut ev2 = event(run_id);
1796        ev2.seq = 4;
1797        ev2.kind = "supervisor.cursor_advanced".into();
1798        ev2.node_id = nid.clone();
1799        ev2.data = serde_json::json!({ "child_run_id": "../etc", "report_seq": 1 });
1800        assert!(matches!(
1801            reduce_event_to_ops(&paths, &ev2),
1802            Err(Error::CorruptEventLog { .. })
1803        ));
1804
1805        // cursor_advanced: missing report_seq.
1806        ev2.data = serde_json::json!({ "child_run_id": "02jxsnap000000000000000000" });
1807        assert!(matches!(
1808            reduce_event_to_ops(&paths, &ev2),
1809            Err(Error::CorruptEventLog { .. })
1810        ));
1811    }
1812
1813    /// Snapshot every projection file under `paths` to a `path → inode` map.
1814    ///
1815    /// An atomic projection write is temp-file + rename, so a rewritten file
1816    /// always lands a *fresh inode* — even when its bytes are byte-for-byte
1817    /// identical (e.g. a manifest op that refreshes `updated_at` to the same
1818    /// timestamp). Comparing inodes therefore detects every write the reducer
1819    /// makes, with no false negatives a content diff would suffer. `events.jsonl`
1820    /// and `.lock` are excluded: `apply_event` never touches them.
1821    #[cfg(unix)]
1822    fn projection_inodes(paths: &RunPaths) -> std::collections::BTreeMap<PathBuf, u64> {
1823        use std::os::unix::fs::MetadataExt;
1824        let mut consider = vec![paths.manifest()];
1825        for dir in [
1826            paths.nodes_dir(),
1827            paths.discussions_dir(),
1828            paths.spinoffs_dir(),
1829        ] {
1830            if let Ok(rd) = std::fs::read_dir(&dir) {
1831                for ent in rd.flatten() {
1832                    let p = ent.path();
1833                    if p.extension().and_then(|s| s.to_str()) == Some("json") {
1834                        consider.push(p);
1835                    }
1836                }
1837            }
1838        }
1839        let mut map = std::collections::BTreeMap::new();
1840        for p in consider {
1841            if let Ok(md) = std::fs::symlink_metadata(&p) {
1842                if md.file_type().is_file() {
1843                    map.insert(p, md.ino());
1844                }
1845            }
1846        }
1847        map
1848    }
1849
1850    /// The exhaustive parity guarantee `projected-paths-into-reducer` requires:
1851    /// for an event applied against a given state, the paths
1852    /// [`plan_projections`] reports MUST equal the files [`apply_event`]
1853    /// actually writes. Plan first (against pre-apply state), apply, then diff
1854    /// the projection inodes — a file is "written" iff it is newly present or
1855    /// its inode changed. `expect_writes` guards the test itself: when set, the
1856    /// touched set must be non-empty, so a kind that silently stopped writing
1857    /// can't pass by matching an empty plan against an empty diff.
1858    #[cfg(unix)]
1859    fn assert_plan_matches_apply(paths: &RunPaths, ev: &Event, expect_writes: bool) {
1860        use std::collections::BTreeSet;
1861        let before = projection_inodes(paths);
1862        let planned: BTreeSet<PathBuf> = plan_projections(paths, ev)
1863            .unwrap_or_else(|e| panic!("plan_projections({}) errored: {e:?}", ev.kind))
1864            .into_iter()
1865            .collect();
1866        apply_event(paths, ev)
1867            .unwrap_or_else(|e| panic!("apply_event({}) errored: {e:?}", ev.kind));
1868        let after = projection_inodes(paths);
1869        let touched: BTreeSet<PathBuf> = after
1870            .iter()
1871            .filter(|(p, ino)| before.get(*p) != Some(*ino))
1872            .map(|(p, _)| p.clone())
1873            .collect();
1874        assert_eq!(
1875            planned, touched,
1876            "kind={}: plan_projections must name exactly the files apply_event writes",
1877            ev.kind
1878        );
1879        if expect_writes {
1880            assert!(
1881                !touched.is_empty(),
1882                "kind={}: expected this event to write at least one projection",
1883                ev.kind
1884            );
1885        }
1886    }
1887
1888    /// Drive every event kind through a dependency-ordered lifecycle on real
1889    /// runs, asserting plan/apply parity at each step. Covers the writing kinds
1890    /// (run/node/discussion/spinoff/supervisor/child) in states where they
1891    /// project, plus the no-op kinds (audit records, `supervisor.exited`,
1892    /// terminal-guarded transitions) where both the plan and the apply touch
1893    /// nothing.
1894    #[cfg(unix)]
1895    #[test]
1896    fn plan_projections_matches_apply_for_every_kind() {
1897        let tmp = TempDir::new().unwrap();
1898        let run_id = "01jxsnap000000000000000000";
1899        let rid = RunId::parse_str(run_id).unwrap();
1900        let dir = crate::run_dir(tmp.path(), &rid);
1901        std::fs::create_dir_all(&dir).unwrap();
1902        let paths = RunPaths::new(dir, run_id).unwrap();
1903        let nid = || Some(NodeId::parse_str("n-0001").unwrap());
1904        let disc_id = "d-pqrstuvwxy";
1905        let prop_id = "s-spinaaaaaa";
1906        let child = "02jxsnap000000000000000000";
1907
1908        // Helper to build a fresh envelope at a monotonic seq.
1909        let mut next_seq = 0u64;
1910        let mut at = |kind: &str, node_id, data| {
1911            next_seq += 1;
1912            Event {
1913                ts: Utc::now(),
1914                seq: next_seq,
1915                kind: kind.into(),
1916                run_id: rid.clone(),
1917                node_id,
1918                idempotency_key: None,
1919                data,
1920            }
1921        };
1922
1923        // run.created → manifest.json
1924        assert_plan_matches_apply(
1925            &paths,
1926            &at(
1927                "run.created",
1928                None,
1929                serde_json::json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
1930            ),
1931            true,
1932        );
1933        // run.status (pending → running) → manifest.json
1934        assert_plan_matches_apply(
1935            &paths,
1936            &at(
1937                "run.status",
1938                None,
1939                serde_json::json!({ "status": "running" }),
1940            ),
1941            true,
1942        );
1943        // node.created → nodes/n-0001.json + manifest.json
1944        assert_plan_matches_apply(
1945            &paths,
1946            &at(
1947                "node.created",
1948                nid(),
1949                serde_json::json!({ "kind": "spinoff" }),
1950            ),
1951            true,
1952        );
1953        // node.status (pending → running) → nodes/n-0001.json
1954        assert_plan_matches_apply(
1955            &paths,
1956            &at(
1957                "node.status",
1958                nid(),
1959                serde_json::json!({ "status": "running" }),
1960            ),
1961            true,
1962        );
1963        // discussion.opened → discussions/<id>.json + manifest.json
1964        assert_plan_matches_apply(
1965            &paths,
1966            &at(
1967                "discussion.opened",
1968                None,
1969                serde_json::json!({ "discussion_id": disc_id, "node_id": "n-0001", "topic": "t" }),
1970            ),
1971            true,
1972        );
1973        // discussion.resolved → discussions/<id>.json + manifest.json
1974        assert_plan_matches_apply(
1975            &paths,
1976            &at(
1977                "discussion.resolved",
1978                None,
1979                serde_json::json!({ "discussion_id": disc_id, "resolution": "keep" }),
1980            ),
1981            true,
1982        );
1983        // spinoff.proposed → spinoffs/<id>.json + manifest.json
1984        assert_plan_matches_apply(
1985            &paths,
1986            &at(
1987                "spinoff.proposed",
1988                None,
1989                serde_json::json!({
1990                    "proposal_id": prop_id, "node_id": "n-0001",
1991                    "proposed_title": "p", "proposed_kind": "spinoff"
1992                }),
1993            ),
1994            true,
1995        );
1996        // spinoff.approved → spinoffs/<id>.json + manifest.json
1997        assert_plan_matches_apply(
1998            &paths,
1999            &at(
2000                "spinoff.approved",
2001                None,
2002                serde_json::json!({ "proposal_id": prop_id, "issue_slug": "x" }),
2003            ),
2004            true,
2005        );
2006        // supervisor.attached → nodes/n-0001.json (still non-terminal)
2007        assert_plan_matches_apply(
2008            &paths,
2009            &at(
2010                "supervisor.attached",
2011                nid(),
2012                serde_json::json!({ "pid": 4242 }),
2013            ),
2014            true,
2015        );
2016        // supervisor.cursor_advanced → nodes/n-0001.json
2017        assert_plan_matches_apply(
2018            &paths,
2019            &at(
2020                "supervisor.cursor_advanced",
2021                nid(),
2022                serde_json::json!({ "child_run_id": child, "report_seq": 3 }),
2023            ),
2024            true,
2025        );
2026        // child.spawned → nodes/n-0001.json (parent node)
2027        assert_plan_matches_apply(
2028            &paths,
2029            &at(
2030                "child.spawned",
2031                nid(),
2032                serde_json::json!({ "child_run_id": child, "child_node_id": "n-0001" }),
2033            ),
2034            true,
2035        );
2036        // node.report success → nodes/n-0001.json (now terminal)
2037        assert_plan_matches_apply(
2038            &paths,
2039            &at("node.report", nid(), serde_json::json!({ "success": true })),
2040            true,
2041        );
2042        // Terminal-guarded no-ops: a settled node swallows further transitions,
2043        // so both the plan and the apply touch nothing.
2044        assert_plan_matches_apply(
2045            &paths,
2046            &at(
2047                "node.status",
2048                nid(),
2049                serde_json::json!({ "status": "failed" }),
2050            ),
2051            false,
2052        );
2053        // No-op audit / lifecycle kinds: zero projections by design.
2054        for kind in [
2055            "supervisor.exited",
2056            "orchestrator.decision",
2057            "discuss.critical",
2058            "cleanup.window_missing",
2059        ] {
2060            assert_plan_matches_apply(&paths, &at(kind, None, serde_json::json!({})), false);
2061        }
2062
2063        // spinoff.rejected needs its own un-settled proposal — exercise it on a
2064        // second proposal id so the approve above doesn't shadow it.
2065        let prop2 = "s-spinbbbbbb";
2066        assert_plan_matches_apply(
2067            &paths,
2068            &at(
2069                "spinoff.proposed",
2070                None,
2071                serde_json::json!({
2072                    "proposal_id": prop2, "node_id": "n-0001",
2073                    "proposed_title": "p2", "proposed_kind": "spinoff"
2074                }),
2075            ),
2076            true,
2077        );
2078        assert_plan_matches_apply(
2079            &paths,
2080            &at(
2081                "spinoff.rejected",
2082                None,
2083                serde_json::json!({ "proposal_id": prop2, "reason": "no" }),
2084            ),
2085            true,
2086        );
2087    }
2088}