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