Skip to main content

octl_core/
reducer.rs

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