Skip to main content

fno_agents/
active_backlog.rs

1//! Active backlog dispatcher: the mission drain-tick core + circuit breaker.
2//!
3//! This module is the engine for the always-on backlog drain. Since x-a4dc (K2)
4//! the drain is MISSION-SCOPED: the daemon's resident supervisor
5//! ([`run_supervisor`]) drives one independent drain loop PER ACTIVE MISSION -
6//! an epic with `mission_active=true`, K1's activation record - not per project.
7//! The legacy per-project interval drain is deleted (epic Locked Decision 4);
8//! merge-triggered `fno backlog advance` is the same-project coverage.
9//!
10//! ## Mission tick (dispatch + reconcile)
11//!
12//! One *tick* first RECONCILES any dispatches fired on a prior tick from events
13//! (feeding [`map_outcome`] -> the auto-defer breaker), then DISPATCHES by
14//! shelling K1's converge core, `fno backlog advance --epic <id> --json`. That
15//! core fans out the epic's ready LEAF children across ALL projects, doing its
16//! own per-dependent-root `walker:<root>` respect, per-project `max_lanes` cap,
17//! and `node:`/`dispatch:` claim dedup - so the mission drain reuses the exact
18//! dispatch logic the merge-advance path uses and never forks it. See
19//! [`dispatch_mission`] / [`mission_drain_tick`] / [`mission_drain_loop`].
20//!
21//! ## Fire-and-forget reconcile (x-0ad6, preserved)
22//!
23//! The tick does NOT own the worker child. `advance --epic` self-mints each
24//! worker session and re-anchors the `node:<id>` claim to `target-session:<sid>`.
25//! A later tick RECONCILES each dispatched node by reading its session id back
26//! from the claim holder and polling its termination event
27//! (`Journal::find_termination`), then feeding the outcome through
28//! [`map_outcome`] - so the auto-defer streak is identical to the supervised
29//! path. A worker that dies without a termination event is caught by the crash
30//! floor (claim gone past the boot window). See [`reconcile_pending`].
31//!
32//! ## Circuit-breaker park (recoverable, per mission)
33//!
34//! When a child fails `failure_limit` consecutive drains the breaker trips and
35//! the daemon `fno backlog defer`s the node (graph state), then resets the
36//! in-memory streak. Independent branches keep dispatching while one branch is
37//! parked. Deferring (not an endlessly-refreshed claim) is what makes the park
38//! recoverable: `fno backlog undefer` returns the node with a fresh
39//! `failure_limit` attempts. The breaker is per mission loop.
40//!
41//! ## Mission liveness
42//!
43//! Each tick re-checks the mission: `advance --epic` reporting `deactivated` or
44//! `all_done`, or the epic dropping out of the resolved target set (its
45//! `mission_active` cleared), RETIRES the loop - no zombie ticks.
46//!
47//! ## Events (Journal contract)
48//!
49//! Every transition emits through [`Journal::append`] (project journal fatal,
50//! global mirror best-effort): `active_backlog_dispatched` / `_parked` /
51//! `_skip` / `_mission_retired`.
52
53use std::collections::HashMap;
54use std::path::{Path, PathBuf};
55use std::sync::atomic::{AtomicBool, Ordering};
56use std::sync::Arc;
57use std::time::Duration;
58
59use serde::Deserialize;
60use serde_json::json;
61
62use crate::claims::{self, ClaimState};
63use crate::events::EventEmitter;
64use crate::loop_dispatch::{fno_cmd, retry_etxtbsy};
65use crate::loop_runtime::{
66    CloseOutcome, Evidence, GlobalJournalPath, Journal, ProjectJournalPath, UnitResult,
67};
68use crate::loopcheck::TerminationReason;
69
70/// Cross-tick per-node consecutive-failure counter (the circuit breaker).
71///
72/// Hermes semantics: increment on a failed drain, reset to zero on a successful
73/// close. When the streak reaches `failure_limit` the caller trips: it
74/// `fno backlog defer`s the node and then [`reset`](Self::reset)s the streak, so
75/// the graph (not an in-memory set) owns the exclusion and `fno backlog undefer`
76/// recovers the node with a fresh `failure_limit` attempts. This struct is the
77/// pure counting policy; the defer IO is the caller's step.
78#[derive(Debug, Default)]
79pub struct CircuitBreaker {
80    failure_limit: u32,
81    failures: HashMap<String, u32>,
82}
83
84impl CircuitBreaker {
85    /// `failure_limit` is clamped to at least 1 (a zero limit would trip every
86    /// node on its first failure, which is never the intent).
87    pub fn new(failure_limit: u32) -> Self {
88        Self {
89            failure_limit: failure_limit.max(1),
90            failures: HashMap::new(),
91        }
92    }
93
94    /// Record a failed drain for `node`. Returns `true` iff this failure trips
95    /// the breaker (the streak just reached `failure_limit`).
96    pub fn record_failure(&mut self, node: &str) -> bool {
97        let n = self.failures.entry(node.to_string()).or_insert(0);
98        *n += 1;
99        *n >= self.failure_limit
100    }
101
102    /// Record a successful close for `node`: clear the streak.
103    pub fn record_success(&mut self, node: &str) {
104        self.failures.remove(node);
105    }
106
107    /// Clear the streak for `node` (called after a trip+defer so a later
108    /// `undefer` gives the node a fresh `failure_limit` attempts).
109    pub fn reset(&mut self, node: &str) {
110        self.failures.remove(node);
111    }
112
113    /// The current consecutive-failure count for `node` (0 if none).
114    pub fn consecutive_failures(&self, node: &str) -> u32 {
115        self.failures.get(node).copied().unwrap_or(0)
116    }
117}
118
119/// Everything one [`mission_drain_tick`] needs, resolved by the daemon per tick.
120///
121/// The dispatch logic lives in `advance --epic` (K1's converge core), so the
122/// daemon carries only what reconcile + the breaker need: the epic id to
123/// converge, the epic's own cwd (roots the journal + node-global `done`/`defer`
124/// reads), the `fno` binary, and the failure limit.
125#[derive(Debug, Clone)]
126pub struct DrainConfig {
127    /// The mission's epic project cwd - roots the journal and the node-global
128    /// `backlog done`/`defer` reads (a mission fans out across projects at
129    /// dispatch time via `advance --epic`, not here).
130    pub cwd: PathBuf,
131    /// The `fno` binary name/path (FNO_BIN override honored by the caller).
132    pub fno_bin: String,
133    /// The active mission's epic id - the `advance --epic <mission>` argument.
134    pub mission: String,
135    /// Cross-tick consecutive-failure limit (the circuit breaker).
136    pub failure_limit: u32,
137}
138
139/// What one [`mission_drain_tick`]'s reconcile did, for tests. Dispatch itself
140/// returns [`MissionDispatch`]; these are the outcomes [`map_outcome`] produces
141/// as it feeds the breaker.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum DrainOutcome {
144    /// A node was reconciled and closed successfully.
145    Dispatched { node: String },
146    /// A node tripped the circuit breaker and was deferred (parked).
147    Parked { node: String, failures: u32 },
148    /// No node to reconcile / dispatch this tick.
149    NoWork,
150    /// The tick could not reconcile a node to a close (a node that failed
151    /// without yet tripping the breaker).
152    Skipped { reason: String },
153}
154
155/// Whether the mission is still live after a dispatch, or should retire its loop.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum MissionDispatch {
158    /// The mission dispatched (or found nothing new); keep ticking.
159    Continue,
160    /// `advance --epic` reported the mission deactivated / all children done.
161    Retire,
162}
163
164/// Best-effort `fno backlog defer <node>` for the circuit-breaker park. Graph
165/// state, recoverable via `fno backlog undefer`. Node ids are global, so the
166/// epic's cwd is a valid working dir for the child-node defer.
167/// `retry_etxtbsy` like every other shellout here - this was the one that
168/// skipped it. A transient busy binary (a concurrent `fno update`, or under
169/// `cargo test` a sibling thread's freshly-written stub) makes the spawn fail,
170/// and `let _ =` swallows it, so a tripped breaker silently never defers its
171/// node and re-dispatches it into the same crash loop.
172/// Returns whether the defer actually landed, so the caller's `parked` journal
173/// row states what happened rather than asserting it. Retrying the spawn is only
174/// half the fix: exhausted retries, any other spawn error, and a NON-ZERO exit
175/// from `fno backlog defer` (unresolvable id, graph lock contention) all still
176/// produce the silent re-dispatch loop described above, and `breaker.reset` runs
177/// either way.
178fn defer_node(fno_bin: &str, cwd: &Path, node: &str, reason: &str) -> bool {
179    match retry_etxtbsy(|| {
180        fno_cmd(fno_bin)
181            .current_dir(cwd)
182            .args(["backlog", "defer", node, "--reason", reason])
183            .output()
184    }) {
185        Ok(out) if out.status.success() => true,
186        Ok(out) => {
187            eprintln!(
188                "active-backlog: defer of {node} failed (exit {:?}): {}",
189                out.status.code(),
190                String::from_utf8_lossy(&out.stderr).trim()
191            );
192            false
193        }
194        Err(e) => {
195            eprintln!("active-backlog: defer of {node} could not run: {e}");
196            false
197        }
198    }
199}
200
201/// Does the node carry a PR reference in graph state? Read through `fno backlog
202/// get` so node resolution stays the CLI's job. FAIL-OPEN: an unreadable or
203/// unparseable answer reports `true`, so a flaky read can never auto-defer a
204/// healthy node.
205fn node_has_pr_ref(cfg: &DrainConfig, node_id: &str) -> bool {
206    // retry_etxtbsy like every other shellout here: a transient busy binary
207    // (a concurrent `fno update`) must not read as "healthy, has PR" and quietly
208    // disable the guard.
209    let Ok(out) = retry_etxtbsy(|| {
210        fno_cmd(&cfg.fno_bin)
211            .args(["backlog", "get", node_id])
212            .current_dir(&cfg.cwd)
213            .output()
214    }) else {
215        return true;
216    };
217    if !out.status.success() {
218        return true;
219    }
220    let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
221        return true;
222    };
223    // A ref must be USABLE, not merely present: `pr_number` an integer and
224    // `pr_url` a non-empty string, matching what the CLI's node_pr_refs can
225    // actually derive a ref from. An empty pr_url is not evidence of a ship.
226    if v.get("pr_number").and_then(|n| n.as_u64()).is_some() {
227        return true;
228    }
229    if v.get("pr_url")
230        .and_then(|u| u.as_str())
231        .is_some_and(|u| !u.trim().is_empty())
232    {
233        return true;
234    }
235    v.get("additional_prs")
236        .and_then(|a| a.as_array())
237        .is_some_and(|a| !a.is_empty())
238}
239
240/// Reconcile passes a ref-less `DonePRGreen` must persist across before it counts
241/// as a dead dispatch. `finalize` stamps `pr_number` AFTER loop-check emits the
242/// termination event, and its tail (plan stamp, handoff, verifier) has no bounded
243/// duration - so a single poll landing in that window would read a healthy ship
244/// as ref-less. Re-checking on a later tick costs nothing and never blocks the
245/// drain thread, which a sleep here would.
246const PR_STAMP_GRACE_TICKS: u32 = 3;
247
248/// Map a dispatched node's termination outcome to a [`DrainOutcome`], updating
249/// the breaker and emitting the decision event. Fed by [`reconcile_pending`]
250/// with the evidence polled from the worker's own termination event, so the
251/// success/park policy is identical to the old supervised path without spawning
252/// a real worker.
253fn map_outcome(
254    cfg: &DrainConfig,
255    breaker: &mut CircuitBreaker,
256    journal: &Journal,
257    reason: &TerminationReason,
258    last_unit: Option<&crate::loop_runtime::UnitResult>,
259) -> DrainOutcome {
260    let Some(last) = last_unit else {
261        // No unit reached close.
262        return match reason {
263            TerminationReason::NoWork => DrainOutcome::NoWork,
264            other => {
265                let _ = journal.append(
266                    "active_backlog_skip",
267                    json!({"reason": "no-close", "termination": format!("{other:?}")}),
268                );
269                DrainOutcome::Skipped {
270                    reason: format!("{other:?}"),
271                }
272            }
273        };
274    };
275
276    let node = last.unit_id.clone();
277
278    // Batch-lane: a member that terminated DoneBatched succeeded - its commits
279    // are on the shared batch branch and it ships via the batch PR, so the node
280    // closes at merge by `fno backlog reconcile`, not here. For the daemon that
281    // is a SUCCESSFUL dispatch, not a failure. Recognize it in the keep-set so a
282    // batched member never trips the cross-tick circuit breaker.
283    if matches!(last.evidence.reason, TerminationReason::DoneBatched) {
284        breaker.record_success(&node);
285        let _ = journal.append(
286            "active_backlog_dispatched",
287            json!({"node_id": node, "termination": "DoneBatched", "batched": true}),
288        );
289        return DrainOutcome::Dispatched { node };
290    }
291
292    // DoneAwaitingMerge: the node built successfully (PR up, reviewed)
293    // but could not merge past a proven pre-existing main-red. That is a
294    // SUCCESSFUL dispatch for the daemon, not a failure - the node is closed at
295    // the human merge by `fno backlog reconcile`, exactly like DoneBatched. Keep
296    // it out of the cross-tick circuit breaker (mirror the DoneBatched keep-set).
297    if matches!(last.evidence.reason, TerminationReason::DoneAwaitingMerge) {
298        breaker.record_success(&node);
299        let _ = journal.append(
300            "active_backlog_dispatched",
301            json!({"node_id": node, "termination": "DoneAwaitingMerge", "awaiting_merge": true}),
302        );
303        return DrainOutcome::Dispatched { node };
304    }
305
306    match &last.close {
307        CloseOutcome::Closed => {
308            breaker.record_success(&node);
309            let _ = journal.append(
310                "active_backlog_dispatched",
311                json!({"node_id": node, "termination": format!("{:?}", last.evidence.reason)}),
312            );
313            DrainOutcome::Dispatched { node }
314        }
315        // x-aba7: an exit-5 (PR OPEN, not merged) close arrives here as
316        // AwaitingMerge with a DonePRGreen reason (the DoneAwaitingMerge-reason
317        // early return above handles the other producer). It is a SUCCESSFUL
318        // dispatch - closed later at the human merge by reconcile - so it must
319        // never trip the cross-tick circuit breaker (mirror the DoneBatched /
320        // DoneAwaitingMerge-reason keep-set). Without this, every healthy
321        // ship-green close would count as a failed drain and auto-defer the node.
322        CloseOutcome::AwaitingMerge => {
323            breaker.record_success(&node);
324            let _ = journal.append(
325                "active_backlog_dispatched",
326                json!({"node_id": node, "awaiting_merge": true, "close": "awaiting-merge"}),
327            );
328            DrainOutcome::Dispatched { node }
329        }
330        CloseOutcome::Parked(detail) | CloseOutcome::Refused(detail) => {
331            let tripped = breaker.record_failure(&node);
332            if tripped {
333                // Park by deferring the node in graph state (recoverable via
334                // `fno backlog undefer`), then reset the streak so a later
335                // undefer gives it a fresh failure_limit attempts.
336                let reason_str = format!(
337                    "auto-failure: {} consecutive failed drains",
338                    cfg.failure_limit
339                );
340                // Recorded, not asserted: `breaker.reset` below hands the node a
341                // fresh streak allowance either way, so a `parked` row claiming
342                // a defer that never landed is what an operator debugging a
343                // re-dispatch loop would be misled by.
344                let deferred = defer_node(&cfg.fno_bin, &cfg.cwd, &node, &reason_str);
345                breaker.reset(&node);
346                let _ = journal.append(
347                    "active_backlog_parked",
348                    json!({"node_id": node, "consecutive_failures": cfg.failure_limit, "detail": detail, "deferred": deferred}),
349                );
350                DrainOutcome::Parked {
351                    node,
352                    failures: cfg.failure_limit,
353                }
354            } else {
355                let _ = journal.append(
356                    "active_backlog_skip",
357                    json!({
358                        "reason": "node-not-closed",
359                        "node_id": node,
360                        "close": detail,
361                        "consecutive_failures": breaker.consecutive_failures(&node),
362                    }),
363                );
364                DrainOutcome::Skipped {
365                    reason: format!("node {node} not closed: {detail}"),
366                }
367            }
368        }
369    }
370}
371
372// ── fire-and-forget reconcile (x-0ad6) ───────────────────────────────────────
373//
374// A tick DISPATCHES the mission's ready children fire-and-forget via K1's
375// converge core (`fno backlog advance --epic`, which routes through `fno agents
376// spawn`, self-mints each worker session, and re-anchors the `node:<id>` claim
377// to `target-session:<sid>`), then RECONCILES prior dispatches from events across
378// later ticks - never owning the worker child.
379//
380// Failure accounting is reconstructed from the worker's own termination event
381// (find_termination on the session id read back from the claim holder) fed
382// through the `map_outcome` policy, so the auto-defer streak is identical by
383// construction. A worker that dies without emitting any termination event is
384// caught by the crash floor (claim gone past the boot window), replacing the
385// awaited-exit-code `node_failed` watchdog the fire-and-forget model can no
386// longer read.
387
388/// A ready node dispatched fire-and-forget in a prior tick, polled to completion
389/// from events.
390#[derive(Debug, Clone)]
391pub struct PendingDispatch {
392    node_id: String,
393    /// The worker's session id, read back from the `node:<id>` claim holder
394    /// (`target-session:<sid>`) once the worker inits and re-anchors the claim.
395    /// `None` until first observed; find_termination cannot be polled before it.
396    session_id: Option<String>,
397    /// Reconcile passes since dispatch. Guards the boot window: a worker that has
398    /// not yet taken the node claim holds none, which must not read as a death
399    /// until `BOOT_GRACE_TICKS` have elapsed.
400    ticks: u32,
401    /// Passes this entry has read a ref-less `DonePRGreen`. Lets the PR stamp land
402    /// before a zero-artifact verdict sticks (see `PR_STAMP_GRACE_TICKS`).
403    stamp_waits: u32,
404}
405
406/// Reconcile passes to wait for a dispatched worker to take its `node:<id>`
407/// claim before a claim-absent verdict counts as a boot crash.
408const BOOT_GRACE_TICKS: u32 = 3;
409
410/// True for the terminal reasons that mark a node done (a successful code or
411/// doc delivery). `DoneBatched`/`DoneAwaitingMerge` are NOT here - they close at
412/// merge and are recognized as success by `map_outcome`'s keep-set instead.
413fn is_done_reason(r: &TerminationReason) -> bool {
414    matches!(
415        r,
416        TerminationReason::DonePRGreen
417            | TerminationReason::DoneAdvisory
418            | TerminationReason::DoneDelivery
419    )
420}
421
422/// Poll each in-flight dispatch and retire the ones that finished, updating the
423/// breaker through `map_outcome` (identical policy to the supervised path).
424/// Resolved entries are removed from `pending`.
425fn reconcile_pending(
426    cfg: &DrainConfig,
427    breaker: &mut CircuitBreaker,
428    pending: &mut Vec<PendingDispatch>,
429    journal: &Journal,
430) {
431    pending.retain_mut(|p| {
432        p.ticks += 1;
433        // `node:<id>` is a GLOBAL-id claim: it routes to $FNO_CLAIMS_ROOT (else
434        // $HOME) by prefix, NOT under the project cwd, so the worker (which
435        // acquires it via `fno claim` with no explicit root) and this read must
436        // resolve the SAME dir. Passing Some(cfg.cwd) would look in the wrong
437        // place and never find the worker's claim (claim_status root mismatch).
438        let (state, rec) = claims::status(&format!("node:{}", p.node_id), None);
439        if let Some(sid) = rec
440            .as_ref()
441            .and_then(|r| r.holder.strip_prefix("target-session:"))
442        {
443            p.session_id = Some(sid.to_string());
444        }
445        // Live/Suspect: the worker (or its TTL) still holds the node claim.
446        // Suspect is a respawned-supervisor worker, never a death (claims.rs).
447        let worker_live = matches!(state, ClaimState::Live | ClaimState::Suspect);
448
449        // A termination event is authoritative whenever we can poll for it,
450        // held claim or not (a worker can terminate a tick before release).
451        if let Some(sid) = p.session_id.clone() {
452            match journal.find_termination(&sid) {
453                Ok(Some(ev)) => {
454                    // A ref-less DonePRGreen may just be racing finalize's stamp;
455                    // keep the entry and re-read on a later tick before deciding.
456                    if matches!(ev.reason, TerminationReason::DonePRGreen)
457                        && !node_has_pr_ref(cfg, &p.node_id)
458                        && p.stamp_waits < PR_STAMP_GRACE_TICKS
459                    {
460                        p.stamp_waits += 1;
461                        return true;
462                    }
463                    resolve_dispatch(cfg, breaker, journal, &p.node_id, ev);
464                    return false;
465                }
466                Ok(None) if !worker_live => {
467                    // Claim gone, session known, no event: the worker died
468                    // mid-flight without terminating. Crash floor -> failure.
469                    resolve_crash(cfg, breaker, journal, &p.node_id);
470                    return false;
471                }
472                _ => {} // still running, or an unreadable journal this pass: keep
473            }
474        } else if !worker_live && p.ticks >= BOOT_GRACE_TICKS {
475            // Never observed the worker take the node claim within the boot
476            // window: the dispatch failed to start. Crash floor -> failure.
477            resolve_crash(cfg, breaker, journal, &p.node_id);
478            return false;
479        }
480        true
481    });
482}
483
484/// Apply a polled termination event to the breaker via the shared `map_outcome`
485/// policy, mirroring the supervised path's `queue.close` side effects.
486fn resolve_dispatch(
487    cfg: &DrainConfig,
488    breaker: &mut CircuitBreaker,
489    journal: &Journal,
490    node_id: &str,
491    ev: Evidence,
492) {
493    // A successful delivery close runs `fno backlog done` (retry_etxtbsy for a
494    // transient busy binary) and Closes only on success - a failed `done` Parks
495    // with the error, so the breaker counts it as a failure (never a false
496    // success). Exit 5 (PR OPEN, not merged) is AwaitingMerge, not a failure:
497    // a no-merge dispatch lands its PR open, so `done` exits 5 and the node
498    // closes at the human merge via reconcile - map_outcome's keep-set counts
499    // it as a successful dispatch. DoneBatched/DoneAwaitingMerge close at merge
500    // via reconcile and are NOT marked here - map_outcome recognizes them too.
501    // Park a dead dispatch BEFORE `fno backlog done`: its merged-PR cross-check
502    // only runs when refs already exist, so a ref-less node would otherwise
503    // close exit 0 and score the dead dispatch as a win.
504    let close = if matches!(ev.reason, TerminationReason::DonePRGreen)
505        && !node_has_pr_ref(cfg, node_id)
506    {
507        CloseOutcome::Parked(
508            "DonePRGreen terminal with no PR ref on the node (zero-artifact dispatch)".to_string(),
509        )
510    } else if is_done_reason(&ev.reason) {
511        match retry_etxtbsy(|| {
512            fno_cmd(&cfg.fno_bin)
513                .args(["backlog", "done", node_id])
514                .current_dir(&cfg.cwd)
515                .output()
516        }) {
517            Ok(o) if o.status.success() => CloseOutcome::Closed,
518            Ok(o) if o.status.code() == Some(5) => CloseOutcome::AwaitingMerge,
519            Ok(o) => {
520                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
521                CloseOutcome::Parked(if stderr.is_empty() {
522                    format!("fno backlog done {node_id} failed (exit {})", o.status)
523                } else {
524                    stderr
525                })
526            }
527            Err(e) => CloseOutcome::Parked(format!("fno backlog done {node_id} spawn failed: {e}")),
528        }
529    } else {
530        CloseOutcome::Parked(format!("session terminated: {:?}", ev.reason))
531    };
532    let reason = ev.reason.clone();
533    let ur = UnitResult {
534        unit_id: node_id.to_string(),
535        evidence: ev,
536        close,
537    };
538    map_outcome(cfg, breaker, journal, &reason, Some(&ur));
539}
540
541/// Crash floor: a dispatched worker died with no termination event. Synthesize
542/// NoProgress evidence and feed the SAME `map_outcome` path, so the failure
543/// counts toward the auto-defer streak exactly as the supervised `node_failed`
544/// watchdog did.
545fn resolve_crash(
546    cfg: &DrainConfig,
547    breaker: &mut CircuitBreaker,
548    journal: &Journal,
549    node_id: &str,
550) {
551    let message = "worker exited with no termination event (fire-and-forget crash floor)";
552    let ur = UnitResult {
553        unit_id: node_id.to_string(),
554        evidence: Evidence {
555            reason: TerminationReason::NoProgress,
556            message: message.to_string(),
557        },
558        close: CloseOutcome::Parked(message.to_string()),
559    };
560    map_outcome(
561        cfg,
562        breaker,
563        journal,
564        &TerminationReason::NoProgress,
565        Some(&ur),
566    );
567}
568
569/// The `fno backlog advance --epic <id> --json` receipt, the only fields the
570/// mission drain reads. `#[serde(default)]` on every field so a partial or
571/// evolving receipt never fails the parse (a missing field defaults benignly).
572#[derive(Debug, Default, Deserialize)]
573struct AdvanceEpicReceipt {
574    #[serde(default)]
575    deactivated: bool,
576    #[serde(default)]
577    all_done: bool,
578    /// Node ids `advance --epic` dispatched this pass (fire-and-forget), to be
579    /// reconciled from events on later ticks.
580    #[serde(default)]
581    dispatched: Vec<String>,
582}
583
584/// Dispatch the mission by shelling K1's converge core, recording each dispatched
585/// child in `pending` for later reconcile. Returns [`MissionDispatch::Retire`]
586/// when `advance --epic` reports the mission deactivated / all children done.
587///
588/// The converge core owns ALL dispatch policy (cross-project fan-out, per-root
589/// `walker:` respect, `max_lanes` cap, claim dedup), so this never forks it. A
590/// non-zero exit or unparseable receipt is a transient skip (Continue) - a truly
591/// gone mission is caught by the loop's re-resolve, not guessed at here.
592fn dispatch_mission(
593    cfg: &DrainConfig,
594    pending: &mut Vec<PendingDispatch>,
595    journal: &Journal,
596) -> MissionDispatch {
597    let out = match retry_etxtbsy(|| {
598        fno_cmd(&cfg.fno_bin)
599            // --continuation: never reactivate the mission and retire an inactive
600            // one, so an operator `--stop` between drain ticks is not undone.
601            .args([
602                "backlog",
603                "advance",
604                "--epic",
605                &cfg.mission,
606                "--continuation",
607                "--json",
608            ])
609            .current_dir(&cfg.cwd)
610            .output()
611    }) {
612        Ok(o) if o.status.success() => o,
613        Ok(o) => {
614            let detail = String::from_utf8_lossy(&o.stderr).trim().to_string();
615            let _ = journal.append(
616                "active_backlog_skip",
617                json!({"reason": "advance-epic-failed", "mission": cfg.mission, "detail": detail}),
618            );
619            return MissionDispatch::Continue;
620        }
621        Err(e) => {
622            let _ = journal.append(
623                "active_backlog_skip",
624                json!({"reason": "advance-epic-failed", "mission": cfg.mission, "detail": format!("{e}")}),
625            );
626            return MissionDispatch::Continue;
627        }
628    };
629    let receipt: AdvanceEpicReceipt = match serde_json::from_slice(&out.stdout) {
630        Ok(r) => r,
631        Err(e) => {
632            let _ = journal.append(
633                "active_backlog_skip",
634                json!({"reason": "advance-epic-unparseable", "mission": cfg.mission, "detail": format!("{e}")}),
635            );
636            return MissionDispatch::Continue;
637        }
638    };
639    if receipt.deactivated || receipt.all_done {
640        return MissionDispatch::Retire;
641    }
642    let mut new_ids = Vec::new();
643    for node_id in &receipt.dispatched {
644        // Guard against re-recording a still-pending node (a prior tick's
645        // dispatch whose worker has not yet closed): advance already dedups by
646        // live claim, but a boot-window respawn could echo the id.
647        if pending.iter().any(|p| p.node_id == *node_id) {
648            continue;
649        }
650        pending.push(PendingDispatch {
651            node_id: node_id.clone(),
652            session_id: None,
653            ticks: 0,
654            stamp_waits: 0,
655        });
656        new_ids.push(node_id.clone());
657    }
658    if !new_ids.is_empty() {
659        let _ = journal.append(
660            "active_backlog_dispatched",
661            json!({"mission": cfg.mission, "dispatched": new_ids, "fire_and_forget": true}),
662        );
663    }
664    MissionDispatch::Continue
665}
666
667/// One mission drain tick: reconcile prior dispatches (feeding the breaker), then
668/// dispatch the mission's currently-ready children. Reconcile runs FIRST so a
669/// child that just auto-deferred is excluded from this tick's `advance --epic`
670/// selection. Synchronous (the loop offloads it to a blocking task).
671pub fn mission_drain_tick(
672    cfg: &DrainConfig,
673    breaker: &mut CircuitBreaker,
674    pending: &mut Vec<PendingDispatch>,
675    journal: &Journal,
676) -> MissionDispatch {
677    reconcile_pending(cfg, breaker, pending, journal);
678    dispatch_mission(cfg, pending, journal)
679}
680
681// ── target resolution + resident supervisor ─────────────────────────────────────
682
683/// One mission drain target as resolved by the Python `fno config
684/// active-backlog --json` helper (an active mission + the epic's workspace path).
685#[derive(Debug, Clone, Deserialize)]
686pub struct ResolvedTarget {
687    /// The mission epic's own project (for keying + cwd resolution).
688    pub project: String,
689    /// The epic project's cwd - roots the loop's journal + node-global reads.
690    pub cwd: String,
691    pub interval_seconds: u64,
692    pub failure_limit: u32,
693    /// The active mission's epic id (the drain's `advance --epic` argument).
694    /// Optional only so a malformed receipt deserializes; a target with no
695    /// mission is skipped by the supervisor.
696    #[serde(default)]
697    pub mission: Option<String>,
698}
699
700/// Shell `fno config active-backlog --json` to discover enabled drain targets.
701/// Best-effort: any failure (missing fno, non-zero exit, unparseable output)
702/// yields an empty list, so the feature simply stays dormant.
703pub fn resolve_targets(fno_bin: &str) -> Vec<ResolvedTarget> {
704    match fno_cmd(fno_bin)
705        .args(["config", "active-backlog", "--json"])
706        .output()
707    {
708        Ok(o) if o.status.success() => serde_json::from_slice(&o.stdout).unwrap_or_default(),
709        _ => Vec::new(),
710    }
711}
712
713/// A project the status-fanout supervisor should tick (x-2057). Enablement is
714/// "has >=1 enabled status sink", INDEPENDENT of the drain's active_backlog set -
715/// a project can fan status out without opting into the backlog drain.
716#[derive(Debug, Clone, serde::Deserialize)]
717struct FanoutTarget {
718    pub project: String,
719    pub cwd: String,
720    pub interval_seconds: u64,
721}
722
723/// Shell `fno config status-sinks --json` to discover fanout targets. Best-effort:
724/// any failure (missing fno, non-zero exit, unparseable output) yields an empty
725/// list, so a broken config never crashes the daemon - it just runs no fanout.
726fn resolve_fanout_targets(fno_bin: &str) -> Vec<FanoutTarget> {
727    match fno_cmd(fno_bin)
728        .args(["config", "status-sinks", "--json"])
729        .output()
730    {
731        Ok(o) if o.status.success() => serde_json::from_slice(&o.stdout).unwrap_or_default(),
732        _ => Vec::new(),
733    }
734}
735
736/// One project's status-fanout loop: shell `fno status-fanout tick` in the
737/// project cwd on the configured cadence, best-effort. Independent of the drain
738/// loops; a tick failure is swallowed and the next tick retries. Between ticks it
739/// re-resolves its own enablement (codex P2): a new `interval_secs` is picked up,
740/// and removing the project's sinks EXITS the loop (so `retain(!is_finished)`
741/// reaps it) rather than ticking forever. Exits on shutdown.
742/// Cap on a single `fno status-fanout tick` child. A legitimately slow tick
743/// (several stalled sinks x (retries+1) x http_timeout + backoff) can reach
744/// minutes; 300s bounds the pathological hang, not normal work.
745const TICK_CHILD_CAP: Duration = Duration::from_secs(300);
746
747/// Await `cmd`'s completion bounded by `cap`, killing the child on timeout.
748/// Returns `true` if the child exceeded the cap and was killed. `kill_on_drop`
749/// is load-bearing: on timeout the `output()` future is dropped, which SIGKILLs
750/// the child - without it a wedged tick parks the loop's shutdown response and
751/// leaks one subprocess per tick. Extracted for unit-testability.
752async fn output_with_cap(mut cmd: tokio::process::Command, cap: Duration) -> bool {
753    cmd.kill_on_drop(true);
754    match tokio::time::timeout(cap, cmd.output()).await {
755        Ok(Ok(_)) => false,
756        // Spawn/exec failure (binary missing, cwd gone, ...) is best-effort like
757        // the tick itself, but log it - a swallowed missing-`fno` is undiagnosable.
758        Ok(Err(e)) => {
759            eprintln!("fanout tick failed to execute: {e}");
760            false
761        }
762        Err(_) => true,
763    }
764}
765
766async fn per_project_fanout_loop(target: FanoutTarget, fno_bin: String, shutdown: Arc<AtomicBool>) {
767    let project = target.project.clone();
768    loop {
769        if shutdown.load(Ordering::SeqCst) {
770            break;
771        }
772        // Re-resolve between ticks so config changes land without a daemon
773        // restart; removing this project's sinks EXITS the loop.
774        let interval = match resolve_fanout_targets(&fno_bin)
775            .into_iter()
776            .find(|t| t.project == project)
777        {
778            Some(t) => Duration::from_secs(t.interval_seconds.max(1)),
779            None => break, // sinks removed for this project -> stop ticking.
780        };
781        let mut cmd = tokio::process::Command::new(&fno_bin);
782        cmd.args(["status-fanout", "tick"]).current_dir(&target.cwd);
783        // Failure otherwise swallowed (next tick retries; at-least-once cursor
784        // semantics). The kill must NOT be silent - the one line below is required.
785        if output_with_cap(cmd, TICK_CHILD_CAP).await {
786            eprintln!(
787                "fanout tick for {project} exceeded {TICK_CHILD_CAP:?}; killed, retrying next tick"
788            );
789        }
790        sleep_interruptible(interval, &shutdown).await;
791    }
792}
793
794/// Build the per-project loop journal (project events.jsonl fatal, global mirror
795/// best-effort) for a drain target's cwd.
796fn journal_for(cwd: &Path) -> Journal {
797    let project_events = cwd.join(".fno").join("events.jsonl");
798    let home = std::env::var("HOME")
799        .map(PathBuf::from)
800        .unwrap_or_else(|_| PathBuf::from("/tmp"));
801    let global_events = home.join(".fno").join("events.jsonl");
802    Journal::new(
803        ProjectJournalPath(project_events),
804        GlobalJournalPath(global_events),
805    )
806}
807
808/// Resolve a [`DrainConfig`] for a mission target, or `None` if the target
809/// carries no mission id (a malformed receipt). No driver-lib preflight: the
810/// worker drivers are resolved per CHILD project inside `advance --epic`, not at
811/// the epic's cwd, so the epic project need not itself be drivable.
812fn drain_config_for(target: &ResolvedTarget, fno_bin: &str) -> Option<DrainConfig> {
813    let mission = target.mission.clone()?;
814    Some(DrainConfig {
815        cwd: PathBuf::from(&target.cwd),
816        fno_bin: fno_bin.to_string(),
817        mission,
818        failure_limit: target.failure_limit,
819    })
820}
821
822/// The wake nudge sentinel path ($HOME/.fno/.active-backlog-nudge by default).
823/// Mirrors the Python writer (`fno.active_backlog.nudge_sentinel_path`) under
824/// the default state dir; a non-default state_dir only loses the latency
825/// optimization, never correctness (the poll floor is the guarantee).
826fn nudge_sentinel_path() -> PathBuf {
827    let home = std::env::var("HOME")
828        .map(PathBuf::from)
829        .unwrap_or_else(|_| PathBuf::from("/tmp"));
830    home.join(".fno").join(".active-backlog-nudge")
831}
832
833/// The sentinel's mtime, or `None` if it does not exist / cannot be stat'd.
834/// The blocking `stat` is offloaded to the blocking pool so polling it every
835/// 500ms never blocks the async executor (gemini finding). `tokio::fs` is not
836/// used to avoid adding the `fs` feature to the tokio dependency.
837async fn nudge_mtime() -> Option<std::time::SystemTime> {
838    tokio::task::spawn_blocking(|| {
839        std::fs::metadata(nudge_sentinel_path())
840            .and_then(|m| m.modified())
841            .ok()
842    })
843    .await
844    .ok()
845    .flatten()
846}
847
848/// Wait up to `total` for the next poll tick, waking EARLY if the nudge sentinel
849/// changes (an event nudge) or `shutdown` flips. `last` carries the mtime across
850/// calls; a burst of touches during a tick coalesces to a single wake because
851/// `last` advances to the newest mtime once, here. The poll floor (`total`) is
852/// the backstop, so a missed nudge just delays a drain by at most one interval.
853async fn wait_for_wake(
854    total: Duration,
855    shutdown: &Arc<AtomicBool>,
856    last: &mut Option<std::time::SystemTime>,
857) {
858    let step = Duration::from_millis(500);
859    let mut elapsed = Duration::ZERO;
860    while elapsed < total {
861        if shutdown.load(Ordering::SeqCst) {
862            return;
863        }
864        let current = nudge_mtime().await;
865        if current != *last {
866            *last = current;
867            return; // event nudge: wake early (coalesced)
868        }
869        let chunk = step.min(total - elapsed);
870        tokio::time::sleep(chunk).await;
871        elapsed += chunk;
872    }
873}
874
875/// The resident drain supervisor (node x-c070).
876///
877/// Spawns ONE independent drain loop per enabled project so a long-running drain
878/// in one project never blocks or starves another (gemini finding). It sets
879/// `live` true whenever there is >=1 enabled target so the daemon's idle-exit
880/// stays out (OQ1 Option A: an enabled but drained board keeps the daemon
881/// resident and polling). Runs until `shutdown` is set, then aborts the
882/// per-project loops; an in-flight `spawn_blocking` tick is not abortable, but
883/// that is safe by design - the dispatched worker owns its `node:<id>` claim
884/// independently and the live-claims filter excludes it on the next start.
885pub async fn run_supervisor(
886    fno_bin: String,
887    emitter: EventEmitter,
888    live: Arc<AtomicBool>,
889    shutdown: Arc<AtomicBool>,
890) {
891    // Mission drain loops, keyed by epic id (x-a4dc K2): one per active mission.
892    let mut tasks: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
893    // Sibling loop family (x-2057): status-fanout ticks, keyed by project. A
894    // separate enablement set (projects with >=1 status sink) from the drain
895    // above, so a sinks-only project fans out without opting into the drain.
896    let mut fanout_tasks: HashMap<String, tokio::task::JoinHandle<()>> = HashMap::new();
897    let recheck = Duration::from_secs(60);
898
899    loop {
900        if shutdown.load(Ordering::SeqCst) {
901            break;
902        }
903        // Drop handles for loops that have exited (a mission retired / deactivated).
904        tasks.retain(|_, h| !h.is_finished());
905        fanout_tasks.retain(|_, h| !h.is_finished());
906
907        let targets = resolve_targets(&fno_bin);
908        let fanout_targets = resolve_fanout_targets(&fno_bin);
909        // `live` keeps the daemon out of idle-exit while ANY supervised work
910        // exists - drain OR fanout. A sink-only project (no active_backlog) must
911        // keep the daemon alive, else the daemon idle-exits and kills its fanout
912        // loop (codex P1).
913        live.store(
914            !targets.is_empty() || !fanout_targets.is_empty(),
915            Ordering::SeqCst,
916        );
917
918        for target in targets {
919            // Key by mission (epic id). A target with no mission is a malformed
920            // receipt; skip it rather than key an unnamed loop.
921            let Some(mission) = target.mission.clone() else {
922                continue;
923            };
924            // Entry API (single lookup): only spawn when this mission has no live
925            // loop yet, mirroring the fanout family below.
926            if let std::collections::hash_map::Entry::Vacant(slot) = tasks.entry(mission) {
927                slot.insert(tokio::spawn(mission_drain_loop(
928                    target,
929                    fno_bin.clone(),
930                    emitter.clone(),
931                    Arc::clone(&shutdown),
932                )));
933            }
934        }
935
936        for ft in fanout_targets {
937            // Entry API: one lookup, and only spawn when this project has no live
938            // loop yet. A loop that already exists self-reconciles config changes.
939            if let std::collections::hash_map::Entry::Vacant(slot) =
940                fanout_tasks.entry(ft.project.clone())
941            {
942                slot.insert(tokio::spawn(per_project_fanout_loop(
943                    ft,
944                    fno_bin.clone(),
945                    Arc::clone(&shutdown),
946                )));
947            }
948        }
949
950        sleep_interruptible(recheck, &shutdown).await;
951    }
952
953    for (_, h) in tasks {
954        h.abort();
955    }
956    for (_, h) in fanout_tasks {
957        h.abort();
958    }
959    live.store(false, Ordering::SeqCst);
960}
961
962/// Sleep `total`, waking early if `shutdown` flips. Checked in small steps so a
963/// long poll interval still tears down promptly at daemon shutdown.
964async fn sleep_interruptible(total: Duration, shutdown: &Arc<AtomicBool>) {
965    let step = Duration::from_millis(500);
966    let mut elapsed = Duration::ZERO;
967    while elapsed < total {
968        if shutdown.load(Ordering::SeqCst) {
969            return;
970        }
971        let chunk = step.min(total - elapsed);
972        tokio::time::sleep(chunk).await;
973        elapsed += chunk;
974    }
975}
976
977/// One mission's independent drain loop: reconcile + dispatch the mission's ready
978/// children, wait the poll floor (or an event nudge), repeat. Owns its own
979/// [`CircuitBreaker`] so failure streaks are per mission. Exits when `shutdown`
980/// flips, the mission drops out of the resolved target set (its `mission_active`
981/// was cleared), or `advance --epic` reports the mission deactivated / all done.
982async fn mission_drain_loop(
983    target: ResolvedTarget,
984    fno_bin: String,
985    emitter: EventEmitter,
986    shutdown: Arc<AtomicBool>,
987) {
988    // A malformed target with no mission is filtered by the supervisor before
989    // spawn; default to empty so this never panics if one slips through (the
990    // re-resolve below then finds no match and exits).
991    let mission = target.mission.clone().unwrap_or_default();
992    let mut breaker = CircuitBreaker::new(target.failure_limit);
993    // In-flight fire-and-forget dispatches, reconciled from events across ticks
994    // (x-0ad6). Resident like the breaker so a worker dispatched one tick is
995    // polled to completion on the next.
996    let mut pending: Vec<PendingDispatch> = Vec::new();
997    let mut last_nudge = nudge_mtime().await;
998    let mut backoff = Duration::from_secs(1);
999
1000    loop {
1001        if shutdown.load(Ordering::SeqCst) {
1002            break;
1003        }
1004
1005        // Re-resolve this mission's liveness. If its epic dropped out of the
1006        // target set (mission_active cleared externally), exit the loop (the
1007        // supervisor will not respawn it).
1008        let current = resolve_targets(&fno_bin)
1009            .into_iter()
1010            .find(|t| t.mission.as_deref() == Some(mission.as_str()));
1011        let Some(t) = current else {
1012            break;
1013        };
1014        let interval = Duration::from_secs(t.interval_seconds.max(1));
1015
1016        let Some(cfg) = drain_config_for(&t, &fno_bin) else {
1017            // Malformed target (no mission id); back off and re-check.
1018            sleep_interruptible(interval, &shutdown).await;
1019            continue;
1020        };
1021        let journal = journal_for(&cfg.cwd);
1022
1023        // The tick is synchronous; offload so the async runtime is never stalled.
1024        // Move the breaker AND pending set in and hand them back so the streak
1025        // and in-flight tracking survive the tick.
1026        let taken_b = std::mem::take(&mut breaker);
1027        let taken_p = std::mem::take(&mut pending);
1028        let handle = tokio::task::spawn_blocking(move || {
1029            let mut b = taken_b;
1030            let mut p = taken_p;
1031            let outcome = mission_drain_tick(&cfg, &mut b, &mut p, &journal);
1032            (outcome, b, p)
1033        });
1034        match handle.await {
1035            Ok((outcome, b, p)) => {
1036                breaker = b;
1037                pending = p;
1038                backoff = Duration::from_secs(1);
1039                if outcome == MissionDispatch::Retire {
1040                    let _ = emitter.emit(
1041                        "active_backlog_mission_retired",
1042                        &json!({"mission": mission}),
1043                    );
1044                    break;
1045                }
1046            }
1047            Err(join_err) => {
1048                let _ = emitter.emit(
1049                    "active_backlog_task_crashed",
1050                    &json!({"mission": mission, "error": join_err.to_string()}),
1051                );
1052                // The panicked breaker's streak is lost (rare); a fresh one is
1053                // safe (a crash-looping node re-accrues failures and re-defers).
1054                // Pending tracking is also lost, but the in-flight workers still
1055                // run and their nodes close at merge via `fno backlog reconcile`.
1056                breaker = CircuitBreaker::new(t.failure_limit);
1057                pending = Vec::new();
1058                sleep_interruptible(backoff, &shutdown).await;
1059                backoff = (backoff * 2).min(Duration::from_secs(60));
1060                continue;
1061            }
1062        }
1063
1064        wait_for_wake(interval, &shutdown, &mut last_nudge).await;
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    #[test]
1073    fn status_fanout_targets_parse_from_json() {
1074        let json = br#"[{"project":"fno","cwd":"/repo/fno","interval_seconds":5}]"#;
1075        let targets: Vec<FanoutTarget> = serde_json::from_slice(json).unwrap();
1076        assert_eq!(targets.len(), 1);
1077        assert_eq!(targets[0].project, "fno");
1078        assert_eq!(targets[0].cwd, "/repo/fno");
1079        assert_eq!(targets[0].interval_seconds, 5);
1080    }
1081
1082    #[test]
1083    fn status_fanout_targets_empty_on_garbage() {
1084        let targets: Vec<FanoutTarget> = serde_json::from_slice(b"not json").unwrap_or_default();
1085        assert!(targets.is_empty());
1086    }
1087
1088    #[tokio::test]
1089    async fn tick_child_killed_at_cap() {
1090        // A tick child that never exits must be dead within cap+epsilon so the
1091        // loop (and daemon shutdown) proceeds, not block on the hung child.
1092        let mut cmd = tokio::process::Command::new("sleep");
1093        cmd.arg("60");
1094        let start = std::time::Instant::now();
1095        let timed_out = output_with_cap(cmd, Duration::from_millis(150)).await;
1096        assert!(timed_out, "a hung child must report timed-out");
1097        assert!(
1098            start.elapsed() < Duration::from_secs(5),
1099            "must return near the cap, not wait on the 60s child"
1100        );
1101    }
1102
1103    #[tokio::test]
1104    async fn tick_child_within_cap_reports_ok() {
1105        // A child that finishes under the cap is not reported as timed-out.
1106        let cmd = tokio::process::Command::new("true");
1107        let timed_out = output_with_cap(cmd, Duration::from_secs(30)).await;
1108        assert!(!timed_out, "a fast child must not be reported as timed-out");
1109    }
1110
1111    #[test]
1112    fn advance_epic_receipt_parses_dispatched_and_liveness() {
1113        // The mission drain reads only dispatched + deactivated + all_done.
1114        let r: AdvanceEpicReceipt = serde_json::from_slice(
1115            br#"{"epic_id":"x-e","error":null,"activated":true,"deactivated":false,
1116                 "all_done":false,"dispatched":["x-a","x-b"],"children":[]}"#,
1117        )
1118        .unwrap();
1119        assert_eq!(r.dispatched, vec!["x-a", "x-b"]);
1120        assert!(!r.deactivated);
1121        assert!(!r.all_done);
1122    }
1123
1124    #[test]
1125    fn advance_epic_receipt_defaults_on_partial_json() {
1126        // A minimal / evolving receipt must never fail the parse (every field
1127        // defaults benignly): no dispatched nodes, mission still live.
1128        let r: AdvanceEpicReceipt = serde_json::from_slice(br#"{"epic_id":"x-e"}"#).unwrap();
1129        assert!(r.dispatched.is_empty());
1130        assert!(!r.deactivated && !r.all_done);
1131    }
1132
1133    #[test]
1134    fn is_done_reason_includes_generic_delivery() {
1135        // The terminal reasons that count as a `backlog done`;
1136        // DoneBatched/DoneAwaitingMerge are the map_outcome keep-set, not here.
1137        assert!(is_done_reason(&TerminationReason::DonePRGreen));
1138        assert!(is_done_reason(&TerminationReason::DoneAdvisory));
1139        assert!(is_done_reason(&TerminationReason::DoneDelivery));
1140        assert!(!is_done_reason(&TerminationReason::DoneBatched));
1141        assert!(!is_done_reason(&TerminationReason::DoneAwaitingMerge));
1142        assert!(!is_done_reason(&TerminationReason::NoProgress));
1143    }
1144
1145    // ── reconcile policy (x-0ad6) ────────────────────────────────────────────
1146    //
1147    // These drive the private reconcile helpers directly with a stub `fno` (for
1148    // the defer/done side effects) + a temp Journal, so the failure-streak policy
1149    // is covered without env-mutating claim setup. The crash-floor boot-grace
1150    // path uses a unique fake node id that is naturally `Free` at the real global
1151    // claims root, so it reads real state for a key that never exists (and never
1152    // writes there).
1153
1154    use std::os::unix::fs::PermissionsExt;
1155
1156    /// Hold this for the whole body of any test that shells `fno_cmd`.
1157    ///
1158    /// `fno_cmd` resolves its binary from the process-global `$FNO_BIN` IN
1159    /// PREFERENCE to the path passed in, and cargo runs a crate's tests as
1160    /// threads in ONE process. So while a sibling test has `FNO_BIN` set to its
1161    /// own stub (`scrape.rs` does exactly this), every stub built here is
1162    /// silently bypassed and that sibling's stub runs instead - which answers
1163    /// nothing this test asked, leaving an empty receipt that
1164    /// `dispatch_mission` correctly treats as a benign skip. The failure
1165    /// therefore surfaces as a plain empty-vec assertion far from its cause,
1166    /// and only under enough parallelism to overlap the two.
1167    ///
1168    /// Reading `$FNO_BIN` needs the lock exactly as much as writing it: the
1169    /// race is reader-vs-writer, so a lock only the writer takes excludes
1170    /// nobody.
1171    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1172        crate::claims::test_env_lock()
1173            .lock()
1174            .unwrap_or_else(|e| e.into_inner())
1175    }
1176
1177    /// A stub `fno` that appends its argv to `record` and exits 0, so a test can
1178    /// assert which `backlog done`/`defer` side effects the reconcile fired.
1179    fn stub_fno(dir: &std::path::Path, record: &std::path::Path) -> String {
1180        std::fs::create_dir_all(dir).unwrap();
1181        let p = dir.join("fno");
1182        std::fs::write(
1183            &p,
1184            format!(
1185                "#!/usr/bin/env bash\necho \"$@\" >> \"{}\"\nexit 0\n",
1186                record.display()
1187            ),
1188        )
1189        .unwrap();
1190        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1191        p.display().to_string()
1192    }
1193
1194    /// Like [`stub_fno`], but `backlog defer` FAILS. `stub_fno` exits 0 for every
1195    /// verb, so the defer failure branch - the one the retry/report exists for -
1196    /// is unreachable with it, and the whole thing passes green when reverted.
1197    fn stub_fno_defer_fails(dir: &std::path::Path, record: &std::path::Path) -> String {
1198        std::fs::create_dir_all(dir).unwrap();
1199        let p = dir.join("fno");
1200        std::fs::write(
1201            &p,
1202            format!(
1203                "#!/usr/bin/env bash\n\
1204                 echo \"$@\" >> \"{}\"\n\
1205                 if [ \"$2\" = \"defer\" ]; then echo 'node not found' >&2; exit 1; fi\n\
1206                 exit 0\n",
1207                record.display()
1208            ),
1209        )
1210        .unwrap();
1211        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1212        p.display().to_string()
1213    }
1214
1215    /// Like [`stub_fno`], but `backlog get` answers with `node_json` on stdout so
1216    /// a test can control whether the node carries a PR ref. Every other verb
1217    /// records its argv and exits 0.
1218    fn stub_fno_get(dir: &std::path::Path, record: &std::path::Path, node_json: &str) -> String {
1219        std::fs::create_dir_all(dir).unwrap();
1220        let p = dir.join("fno");
1221        std::fs::write(
1222            &p,
1223            format!(
1224                "#!/usr/bin/env bash\n\
1225                 if [ \"$2\" = \"get\" ]; then printf '%s' '{}'; exit 0; fi\n\
1226                 echo \"$@\" >> \"{}\"\nexit 0\n",
1227                node_json,
1228                record.display()
1229            ),
1230        )
1231        .unwrap();
1232        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1233        p.display().to_string()
1234    }
1235
1236    fn test_cfg(tmp: &std::path::Path, fno_bin: String, failure_limit: u32) -> DrainConfig {
1237        DrainConfig {
1238            cwd: tmp.to_path_buf(),
1239            fno_bin,
1240            mission: "x-epic".to_string(),
1241            failure_limit,
1242        }
1243    }
1244
1245    fn test_journal(tmp: &std::path::Path) -> (Journal, PathBuf) {
1246        let project = tmp.join(".fno").join("events.jsonl");
1247        let global = tmp.join("global-events.jsonl");
1248        std::fs::create_dir_all(project.parent().unwrap()).unwrap();
1249        (Journal::new_raw(project.clone(), global), project)
1250    }
1251
1252    fn journal_lines(p: &std::path::Path) -> Vec<String> {
1253        std::fs::read_to_string(p)
1254            .unwrap_or_default()
1255            .lines()
1256            .map(str::to_string)
1257            .collect()
1258    }
1259
1260    #[test]
1261    fn resolve_dispatch_done_records_success_and_marks_done() {
1262        let _env = env_guard();
1263        let tmp = tempfile::TempDir::new().unwrap();
1264        let record = tmp.path().join("fno-calls.txt");
1265        let fno = stub_fno(&tmp.path().join("bin"), &record);
1266        let cfg = test_cfg(tmp.path(), fno, 3);
1267        let (journal, project_journal) = test_journal(tmp.path());
1268        let mut breaker = CircuitBreaker::new(3);
1269        breaker.record_failure("x-suc0001"); // pre-existing streak to prove reset
1270
1271        resolve_dispatch(
1272            &cfg,
1273            &mut breaker,
1274            &journal,
1275            "x-suc0001",
1276            Evidence {
1277                reason: TerminationReason::DonePRGreen,
1278                message: "done".to_string(),
1279            },
1280        );
1281
1282        assert_eq!(
1283            breaker.consecutive_failures("x-suc0001"),
1284            0,
1285            "success resets the streak"
1286        );
1287        // is_done_reason -> the reconcile marks the node done (mirrors queue.close).
1288        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1289        assert!(calls.contains("backlog done x-suc0001"), "calls: {calls}");
1290        assert!(journal_lines(&project_journal)
1291            .iter()
1292            .any(|l| l.contains("active_backlog_dispatched") && l.contains("x-suc0001")));
1293    }
1294
1295    #[test]
1296    fn resolve_dispatch_done_pr_green_without_pr_ref_is_a_failure() {
1297        let _env = env_guard();
1298        // The dead-dispatch signature. A DonePRGreen terminal asserts a
1299        // PR; a node carrying none means the worker died leaving nothing. It must
1300        // count toward the streak AND must not `backlog done` (whose merged-PR
1301        // cross-check is skipped entirely for a ref-less node).
1302        let tmp = tempfile::TempDir::new().unwrap();
1303        let record = tmp.path().join("fno-calls.txt");
1304        let fno = stub_fno_get(
1305            &tmp.path().join("bin"),
1306            &record,
1307            r#"{"id":"x-dead0001","status":"in_review"}"#,
1308        );
1309        let cfg = test_cfg(tmp.path(), fno, 3);
1310        let (journal, project_journal) = test_journal(tmp.path());
1311        let mut breaker = CircuitBreaker::new(3);
1312
1313        resolve_dispatch(
1314            &cfg,
1315            &mut breaker,
1316            &journal,
1317            "x-dead0001",
1318            Evidence {
1319                reason: TerminationReason::DonePRGreen,
1320                message: "promised".to_string(),
1321            },
1322        );
1323
1324        assert_eq!(
1325            breaker.consecutive_failures("x-dead0001"),
1326            1,
1327            "a zero-artifact DonePRGreen counts toward the streak"
1328        );
1329        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1330        assert!(
1331            !calls.contains("backlog done"),
1332            "must not close a node whose terminal lied: {calls}"
1333        );
1334        assert!(journal_lines(&project_journal)
1335            .iter()
1336            .any(|l| l.contains("active_backlog_skip") && l.contains("x-dead0001")));
1337    }
1338
1339    #[test]
1340    fn resolve_dispatch_done_pr_green_with_pr_ref_is_success() {
1341        let _env = env_guard();
1342        // The healthy counterpart: a PR ref present means the terminal told the
1343        // truth, so the existing close path runs untouched.
1344        let tmp = tempfile::TempDir::new().unwrap();
1345        let record = tmp.path().join("fno-calls.txt");
1346        let fno = stub_fno_get(
1347            &tmp.path().join("bin"),
1348            &record,
1349            r#"{"id":"x-live0001","pr_number":477}"#,
1350        );
1351        let cfg = test_cfg(tmp.path(), fno, 3);
1352        let (journal, _pj) = test_journal(tmp.path());
1353        let mut breaker = CircuitBreaker::new(3);
1354
1355        resolve_dispatch(
1356            &cfg,
1357            &mut breaker,
1358            &journal,
1359            "x-live0001",
1360            Evidence {
1361                reason: TerminationReason::DonePRGreen,
1362                message: String::new(),
1363            },
1364        );
1365
1366        assert_eq!(breaker.consecutive_failures("x-live0001"), 0);
1367        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1368        assert!(calls.contains("backlog done x-live0001"), "calls: {calls}");
1369    }
1370
1371    #[test]
1372    fn zero_artifact_check_fails_open_on_unreadable_node() {
1373        let _env = env_guard();
1374        // Fail-open is the safety property: an unparseable `backlog get` must
1375        // never auto-defer a healthy node. `stub_fno` prints nothing, so the
1376        // parse fails and the node reports as PR-bearing.
1377        let tmp = tempfile::TempDir::new().unwrap();
1378        let record = tmp.path().join("fno-calls.txt");
1379        let fno = stub_fno(&tmp.path().join("bin"), &record);
1380        let cfg = test_cfg(tmp.path(), fno, 3);
1381
1382        assert!(
1383            node_has_pr_ref(&cfg, "x-unknown1"),
1384            "unreadable node must fail open"
1385        );
1386    }
1387
1388    #[test]
1389    fn pr_ref_read_unions_additional_prs() {
1390        let _env = env_guard();
1391        // The CLI's node_pr_refs unions additional_prs; if this predicate did not,
1392        // a node whose only ref lives there would read as a dead dispatch.
1393        let tmp = tempfile::TempDir::new().unwrap();
1394        let record = tmp.path().join("fno-calls.txt");
1395        let fno = stub_fno_get(
1396            &tmp.path().join("bin"),
1397            &record,
1398            r#"{"id":"x-addl0001","additional_prs":[{"number":12}]}"#,
1399        );
1400        let cfg = test_cfg(tmp.path(), fno, 3);
1401
1402        assert!(node_has_pr_ref(&cfg, "x-addl0001"));
1403    }
1404
1405    #[test]
1406    fn empty_pr_url_is_not_a_ref() {
1407        let _env = env_guard();
1408        // A ref must be usable: `--pr-url ""` is present-but-empty and the CLI
1409        // can derive no ref from it, so it must not read as evidence of a ship.
1410        let tmp = tempfile::TempDir::new().unwrap();
1411        let record = tmp.path().join("fno-calls.txt");
1412        let fno = stub_fno_get(
1413            &tmp.path().join("bin"),
1414            &record,
1415            r#"{"id":"x-empt0001","pr_url":"  "}"#,
1416        );
1417        let cfg = test_cfg(tmp.path(), fno, 3);
1418
1419        assert!(!node_has_pr_ref(&cfg, "x-empt0001"));
1420    }
1421
1422    #[test]
1423    fn resolve_dispatch_advisory_without_pr_ref_is_still_success() {
1424        let _env = env_guard();
1425        // DoneAdvisory is a doc terminal with no PR by design - the zero-artifact
1426        // guard must not touch it, or every doc run would trip the breaker.
1427        let tmp = tempfile::TempDir::new().unwrap();
1428        let record = tmp.path().join("fno-calls.txt");
1429        let fno = stub_fno_get(
1430            &tmp.path().join("bin"),
1431            &record,
1432            r#"{"id":"x-doc00001","status":"in_review"}"#,
1433        );
1434        let cfg = test_cfg(tmp.path(), fno, 3);
1435        let (journal, _pj) = test_journal(tmp.path());
1436        let mut breaker = CircuitBreaker::new(3);
1437
1438        resolve_dispatch(
1439            &cfg,
1440            &mut breaker,
1441            &journal,
1442            "x-doc00001",
1443            Evidence {
1444                reason: TerminationReason::DoneAdvisory,
1445                message: String::new(),
1446            },
1447        );
1448
1449        assert_eq!(breaker.consecutive_failures("x-doc00001"), 0);
1450        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1451        assert!(calls.contains("backlog done x-doc00001"), "calls: {calls}");
1452    }
1453
1454    #[test]
1455    fn resolve_dispatch_awaiting_merge_is_success_without_done() {
1456        let _env = env_guard();
1457        // DoneAwaitingMerge is a successful dispatch (closes at merge via
1458        // reconcile) - the keep-set records success but must NOT `backlog done`.
1459        let tmp = tempfile::TempDir::new().unwrap();
1460        let record = tmp.path().join("fno-calls.txt");
1461        let fno = stub_fno(&tmp.path().join("bin"), &record);
1462        let cfg = test_cfg(tmp.path(), fno, 3);
1463        let (journal, _pj) = test_journal(tmp.path());
1464        let mut breaker = CircuitBreaker::new(3);
1465        breaker.record_failure("x-awm0001");
1466
1467        resolve_dispatch(
1468            &cfg,
1469            &mut breaker,
1470            &journal,
1471            "x-awm0001",
1472            Evidence {
1473                reason: TerminationReason::DoneAwaitingMerge,
1474                message: String::new(),
1475            },
1476        );
1477
1478        assert_eq!(breaker.consecutive_failures("x-awm0001"), 0);
1479        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1480        assert!(
1481            !calls.contains("backlog done"),
1482            "awaiting-merge must not mark done: {calls}"
1483        );
1484    }
1485
1486    #[test]
1487    fn resolve_dispatch_failed_done_records_failure_not_false_success() {
1488        // If `fno backlog done` FAILS, the node was not actually closed, so the
1489        // dispatch must Park (a failure toward the streak), never a false success.
1490        // Regression guard for the review finding.
1491        let _env = env_guard();
1492        let tmp = tempfile::TempDir::new().unwrap();
1493        let bin = tmp.path().join("bin");
1494        std::fs::create_dir_all(&bin).unwrap();
1495        let fno = bin.join("fno");
1496        std::fs::write(
1497            &fno,
1498            "#!/usr/bin/env bash\nif [[ \"$1\" == backlog && \"$2\" == done ]]; then echo 'node has open blockers' >&2; exit 1; fi\nexit 0\n",
1499        )
1500        .unwrap();
1501        std::fs::set_permissions(&fno, std::fs::Permissions::from_mode(0o755)).unwrap();
1502        let cfg = test_cfg(tmp.path(), fno.display().to_string(), 3);
1503        let (journal, _pj) = test_journal(tmp.path());
1504        let mut breaker = CircuitBreaker::new(3);
1505
1506        resolve_dispatch(
1507            &cfg,
1508            &mut breaker,
1509            &journal,
1510            "x-donefail",
1511            Evidence {
1512                reason: TerminationReason::DonePRGreen,
1513                message: "done".to_string(),
1514            },
1515        );
1516
1517        assert_eq!(
1518            breaker.consecutive_failures("x-donefail"),
1519            1,
1520            "a failed `backlog done` must count as a failure, not a false success"
1521        );
1522    }
1523
1524    #[test]
1525    fn resolve_dispatch_done_exit5_is_awaiting_merge_success() {
1526        // x-aba7: a no-merge dispatch lands its PR OPEN, so `fno backlog done`
1527        // exits 5 (awaiting merge). That is a SUCCESSFUL dispatch (the node
1528        // closes at the human merge via reconcile), so the breaker must NOT
1529        // record a failure for the exit-5 awaiting-merge mapping.
1530        let _env = env_guard();
1531        let tmp = tempfile::TempDir::new().unwrap();
1532        let bin = tmp.path().join("bin");
1533        std::fs::create_dir_all(&bin).unwrap();
1534        let fno = bin.join("fno");
1535        std::fs::write(
1536            &fno,
1537            "#!/usr/bin/env bash\nif [[ \"$1\" == backlog && \"$2\" == done ]]; then echo 'awaiting merge: PR OPEN' >&2; exit 5; fi\nexit 0\n",
1538        )
1539        .unwrap();
1540        std::fs::set_permissions(&fno, std::fs::Permissions::from_mode(0o755)).unwrap();
1541        let cfg = test_cfg(tmp.path(), fno.display().to_string(), 3);
1542        let (journal, project_journal) = test_journal(tmp.path());
1543        let mut breaker = CircuitBreaker::new(3);
1544        breaker.record_failure("x-awm5001"); // pre-existing streak to prove reset
1545
1546        resolve_dispatch(
1547            &cfg,
1548            &mut breaker,
1549            &journal,
1550            "x-awm5001",
1551            Evidence {
1552                reason: TerminationReason::DonePRGreen,
1553                message: "done".to_string(),
1554            },
1555        );
1556
1557        assert_eq!(
1558            breaker.consecutive_failures("x-awm5001"),
1559            0,
1560            "done exit 5 (awaiting merge) is a success, never a failure"
1561        );
1562        assert!(journal_lines(&project_journal)
1563            .iter()
1564            .any(|l| l.contains("active_backlog_dispatched") && l.contains("awaiting_merge")));
1565    }
1566
1567    #[test]
1568    fn resolve_crash_at_limit_defers_and_parks() {
1569        let _env = env_guard();
1570        // AC1-FR: a worker death (no termination event) counts as a failure; the
1571        // Nth consecutive death trips the breaker -> defer + parked event.
1572        let tmp = tempfile::TempDir::new().unwrap();
1573        let record = tmp.path().join("fno-calls.txt");
1574        let fno = stub_fno(&tmp.path().join("bin"), &record);
1575        let cfg = test_cfg(tmp.path(), fno, 2);
1576        let (journal, project_journal) = test_journal(tmp.path());
1577        let mut breaker = CircuitBreaker::new(2);
1578
1579        resolve_crash(&cfg, &mut breaker, &journal, "x-cra0001"); // failure 1/2
1580        assert_eq!(breaker.consecutive_failures("x-cra0001"), 1);
1581        resolve_crash(&cfg, &mut breaker, &journal, "x-cra0001"); // failure 2/2 -> trip
1582
1583        // Trip defers the node (graph exclusion) and resets the streak.
1584        assert_eq!(breaker.consecutive_failures("x-cra0001"), 0);
1585        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1586        assert!(calls.contains("backlog defer x-cra0001"), "calls: {calls}");
1587        let parked = journal_lines(&project_journal)
1588            .into_iter()
1589            .find(|l| l.contains("active_backlog_parked") && l.contains("x-cra0001"))
1590            .expect("parked event");
1591        // A defer that landed is recorded as such, not assumed.
1592        assert!(parked.contains("\"deferred\":true"), "parked: {parked}");
1593    }
1594
1595    #[test]
1596    fn park_records_a_defer_that_did_not_land() {
1597        let _env = env_guard();
1598        // `breaker.reset` runs whether or not the defer succeeded, so a `parked`
1599        // row that ASSERTS the park misleads whoever debugs the resulting
1600        // re-dispatch loop: the node is back with a fresh streak allowance and
1601        // the journal says it was parked. Spawning the child is not evidence it
1602        // worked - the stub every other test uses exits 0 for every verb, which
1603        // is why reverting the report left the suite green.
1604        let tmp = tempfile::TempDir::new().unwrap();
1605        let record = tmp.path().join("fno-calls.txt");
1606        let fno = stub_fno_defer_fails(&tmp.path().join("bin"), &record);
1607        let cfg = test_cfg(tmp.path(), fno, 2);
1608        let (journal, project_journal) = test_journal(tmp.path());
1609        let mut breaker = CircuitBreaker::new(2);
1610
1611        resolve_crash(&cfg, &mut breaker, &journal, "x-cra0002");
1612        resolve_crash(&cfg, &mut breaker, &journal, "x-cra0002"); // trips
1613
1614        let calls = std::fs::read_to_string(&record).unwrap_or_default();
1615        assert!(calls.contains("backlog defer x-cra0002"), "calls: {calls}");
1616        let parked = journal_lines(&project_journal)
1617            .into_iter()
1618            .find(|l| l.contains("active_backlog_parked") && l.contains("x-cra0002"))
1619            .expect("parked event still emitted on a failed defer");
1620        assert!(
1621            parked.contains("\"deferred\":false"),
1622            "a defer that exited non-zero must be recorded as not landed: {parked}"
1623        );
1624    }
1625
1626    #[test]
1627    fn reconcile_boot_grace_then_crash_floor() {
1628        let _env = env_guard();
1629        // A dispatched worker that never takes its `node:<id>` claim (never
1630        // booted) is kept for BOOT_GRACE_TICKS reconcile passes, then counted as
1631        // a crash. Uses a unique fake node id (naturally Free at the global root).
1632        let tmp = tempfile::TempDir::new().unwrap();
1633        let record = tmp.path().join("fno-calls.txt");
1634        let fno = stub_fno(&tmp.path().join("bin"), &record);
1635        let cfg = test_cfg(tmp.path(), fno, 3);
1636        let (journal, _pj) = test_journal(tmp.path());
1637        let mut breaker = CircuitBreaker::new(3);
1638        let mut pending = vec![PendingDispatch {
1639            node_id: "x-bootgrace-never-real".to_string(),
1640            session_id: None,
1641            ticks: 0,
1642            stamp_waits: 0,
1643        }];
1644
1645        // Passes before the grace expires keep the dispatch and record nothing.
1646        for _ in 1..BOOT_GRACE_TICKS {
1647            reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1648            assert_eq!(
1649                pending.len(),
1650                1,
1651                "must keep the dispatch during the boot window"
1652            );
1653            assert_eq!(breaker.consecutive_failures("x-bootgrace-never-real"), 0);
1654        }
1655        // The pass that reaches the grace counts a crash-floor failure and drops it.
1656        reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1657        assert!(
1658            pending.is_empty(),
1659            "the never-booted dispatch is retired as a crash"
1660        );
1661        assert_eq!(breaker.consecutive_failures("x-bootgrace-never-real"), 1);
1662    }
1663
1664    #[test]
1665    fn refless_done_pr_green_waits_for_the_stamp_before_parking() {
1666        let _env = env_guard();
1667        // finalize stamps pr_number after loop-check emits termination, and its
1668        // tail is unbounded - so a ref-less read is held across ticks rather than
1669        // decided on the spot. Only a dispatch still ref-less after the grace is
1670        // a dead dispatch.
1671        let tmp = tempfile::TempDir::new().unwrap();
1672        let record = tmp.path().join("fno-calls.txt");
1673        let fno = stub_fno_get(
1674            &tmp.path().join("bin"),
1675            &record,
1676            r#"{"id":"x-grace-never-real"}"#,
1677        );
1678        let cfg = test_cfg(tmp.path(), fno, 3);
1679        let (journal, project_journal) = test_journal(tmp.path());
1680        std::fs::write(
1681            &project_journal,
1682            "{\"type\":\"termination\",\"data\":{\"session_id\":\"sid-grace\",\"reason\":\"DonePRGreen\"}}\n",
1683        )
1684        .unwrap();
1685        let mut breaker = CircuitBreaker::new(3);
1686        let mut pending = vec![PendingDispatch {
1687            node_id: "x-grace-never-real".to_string(),
1688            session_id: Some("sid-grace".to_string()),
1689            ticks: 0,
1690            stamp_waits: 0,
1691        }];
1692
1693        for _ in 0..PR_STAMP_GRACE_TICKS {
1694            reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1695            assert_eq!(pending.len(), 1, "held while the stamp may still land");
1696            assert_eq!(breaker.consecutive_failures("x-grace-never-real"), 0);
1697        }
1698
1699        reconcile_pending(&cfg, &mut breaker, &mut pending, &journal);
1700        assert!(
1701            pending.is_empty(),
1702            "grace exhausted: the dispatch is retired"
1703        );
1704        assert_eq!(
1705            breaker.consecutive_failures("x-grace-never-real"),
1706            1,
1707            "a still-ref-less DonePRGreen counts toward the streak"
1708        );
1709    }
1710
1711    #[test]
1712    fn resolved_target_parses_mission_target() {
1713        // The Python emitter's mission-target shape round-trips; a receipt with
1714        // no mission deserializes (mission=None) so the supervisor can skip it.
1715        let t: ResolvedTarget = serde_json::from_str(
1716            r#"{"project":"fno","cwd":"/x","interval_seconds":60,"failure_limit":3,"mission":"x-epic"}"#,
1717        )
1718        .unwrap();
1719        assert_eq!(t.mission.as_deref(), Some("x-epic"));
1720        let no_mission: ResolvedTarget = serde_json::from_str(
1721            r#"{"project":"p","cwd":"/x","interval_seconds":60,"failure_limit":3}"#,
1722        )
1723        .unwrap();
1724        assert_eq!(no_mission.mission, None);
1725    }
1726
1727    #[test]
1728    fn breaker_trips_at_limit() {
1729        let mut b = CircuitBreaker::new(3);
1730        assert!(!b.record_failure("n1"));
1731        assert_eq!(b.consecutive_failures("n1"), 1);
1732        assert!(!b.record_failure("n1"));
1733        assert_eq!(b.consecutive_failures("n1"), 2);
1734        // third failure trips
1735        assert!(b.record_failure("n1"));
1736        assert_eq!(b.consecutive_failures("n1"), 3);
1737    }
1738
1739    #[test]
1740    fn breaker_success_resets_streak() {
1741        let mut b = CircuitBreaker::new(2);
1742        b.record_failure("n1");
1743        assert_eq!(b.consecutive_failures("n1"), 1);
1744        b.record_success("n1");
1745        assert_eq!(b.consecutive_failures("n1"), 0);
1746        // a fresh streak starts after the success
1747        assert!(!b.record_failure("n1"));
1748        assert!(b.record_failure("n1"));
1749    }
1750
1751    #[test]
1752    fn breaker_reset_gives_fresh_attempts() {
1753        // Models trip -> defer -> reset: after a reset the node gets a fresh
1754        // failure_limit run (the undefer-recovery contract).
1755        let mut b = CircuitBreaker::new(2);
1756        assert!(!b.record_failure("n1"));
1757        assert!(b.record_failure("n1")); // trips
1758        b.reset("n1"); // caller deferred + reset
1759        assert_eq!(b.consecutive_failures("n1"), 0);
1760        assert!(!b.record_failure("n1")); // fresh streak
1761        assert!(b.record_failure("n1")); // trips again
1762    }
1763
1764    #[test]
1765    fn breaker_tracks_nodes_independently() {
1766        let mut b = CircuitBreaker::new(2);
1767        b.record_failure("a");
1768        b.record_failure("b");
1769        assert_eq!(b.consecutive_failures("a"), 1);
1770        assert_eq!(b.consecutive_failures("b"), 1);
1771        assert!(b.record_failure("a")); // a trips
1772        assert_eq!(b.consecutive_failures("b"), 1); // b unaffected
1773    }
1774
1775    #[test]
1776    fn zero_limit_is_clamped_to_one() {
1777        let mut b = CircuitBreaker::new(0);
1778        // clamped to 1: first failure trips
1779        assert!(b.record_failure("n1"));
1780    }
1781
1782    /// A stub `fno` whose `backlog advance --epic` prints a fixed JSON receipt on
1783    /// stdout (exit 0). Any other subcommand is a no-op exit 0.
1784    fn stub_fno_advance(dir: &std::path::Path, receipt_json: &str) -> String {
1785        std::fs::create_dir_all(dir).unwrap();
1786        let p = dir.join("fno");
1787        std::fs::write(
1788            &p,
1789            format!(
1790                "#!/usr/bin/env bash\nif [[ \"$1\" == backlog && \"$2\" == advance ]]; then \
1791                 cat <<'JSON'\n{receipt_json}\nJSON\nfi\nexit 0\n"
1792            ),
1793        )
1794        .unwrap();
1795        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1796        p.display().to_string()
1797    }
1798
1799    #[test]
1800    fn dispatch_mission_records_dispatched_and_continues() {
1801        let _env = env_guard();
1802        let tmp = tempfile::TempDir::new().unwrap();
1803        let fno = stub_fno_advance(
1804            &tmp.path().join("bin"),
1805            r#"{"epic_id":"x-epic","deactivated":false,"all_done":false,"dispatched":["x-a","x-b"]}"#,
1806        );
1807        let cfg = test_cfg(tmp.path(), fno, 3);
1808        let (journal, project_journal) = test_journal(tmp.path());
1809        let mut pending = Vec::new();
1810
1811        let outcome = dispatch_mission(&cfg, &mut pending, &journal);
1812        assert_eq!(outcome, MissionDispatch::Continue);
1813        assert_eq!(
1814            pending
1815                .iter()
1816                .map(|p| p.node_id.clone())
1817                .collect::<Vec<_>>(),
1818            vec!["x-a", "x-b"]
1819        );
1820        assert!(journal_lines(&project_journal)
1821            .iter()
1822            .any(|l| l.contains("active_backlog_dispatched") && l.contains("x-a")));
1823    }
1824
1825    #[test]
1826    fn dispatch_mission_retires_on_deactivated() {
1827        let _env = env_guard();
1828        let tmp = tempfile::TempDir::new().unwrap();
1829        let fno = stub_fno_advance(
1830            &tmp.path().join("bin"),
1831            r#"{"epic_id":"x-epic","deactivated":true,"all_done":false,"dispatched":[]}"#,
1832        );
1833        let cfg = test_cfg(tmp.path(), fno, 3);
1834        let (journal, _pj) = test_journal(tmp.path());
1835        let mut pending = Vec::new();
1836        assert_eq!(
1837            dispatch_mission(&cfg, &mut pending, &journal),
1838            MissionDispatch::Retire
1839        );
1840    }
1841
1842    #[test]
1843    fn dispatch_mission_retires_on_all_done() {
1844        let _env = env_guard();
1845        let tmp = tempfile::TempDir::new().unwrap();
1846        let fno = stub_fno_advance(
1847            &tmp.path().join("bin"),
1848            r#"{"epic_id":"x-epic","deactivated":false,"all_done":true,"dispatched":[]}"#,
1849        );
1850        let cfg = test_cfg(tmp.path(), fno, 3);
1851        let (journal, _pj) = test_journal(tmp.path());
1852        let mut pending = Vec::new();
1853        assert_eq!(
1854            dispatch_mission(&cfg, &mut pending, &journal),
1855            MissionDispatch::Retire
1856        );
1857    }
1858
1859    #[test]
1860    fn dispatch_mission_dedups_already_pending() {
1861        let _env = env_guard();
1862        // A boot-window re-echo of a still-pending node must not double-record it.
1863        let tmp = tempfile::TempDir::new().unwrap();
1864        let fno = stub_fno_advance(
1865            &tmp.path().join("bin"),
1866            r#"{"epic_id":"x-epic","dispatched":["x-a"]}"#,
1867        );
1868        let cfg = test_cfg(tmp.path(), fno, 3);
1869        let (journal, _pj) = test_journal(tmp.path());
1870        let mut pending = vec![PendingDispatch {
1871            node_id: "x-a".to_string(),
1872            session_id: None,
1873            ticks: 2,
1874            stamp_waits: 0,
1875        }];
1876        dispatch_mission(&cfg, &mut pending, &journal);
1877        assert_eq!(pending.len(), 1, "x-a already pending must not be re-added");
1878    }
1879
1880    #[test]
1881    fn dispatch_mission_unparseable_receipt_continues() {
1882        let _env = env_guard();
1883        // A garbled receipt is a transient skip (Continue), never a crash or a
1884        // false Retire (the loop's re-resolve catches a truly gone mission).
1885        let tmp = tempfile::TempDir::new().unwrap();
1886        let fno = stub_fno_advance(&tmp.path().join("bin"), "wedged python traceback");
1887        let cfg = test_cfg(tmp.path(), fno, 3);
1888        let (journal, project_journal) = test_journal(tmp.path());
1889        let mut pending = Vec::new();
1890        assert_eq!(
1891            dispatch_mission(&cfg, &mut pending, &journal),
1892            MissionDispatch::Continue
1893        );
1894        assert!(pending.is_empty());
1895        assert!(journal_lines(&project_journal)
1896            .iter()
1897            .any(|l| l.contains("advance-epic-unparseable")));
1898    }
1899}