Skip to main content

fno_agents/
needs.rs

1//! `fno-agents needs` - the needs-me-queue events-fold leg (x-feec).
2//!
3//! A pure read-time fold over events.jsonl producing the two event-derived
4//! attention reasons the mux client cannot see from live badges alone:
5//!   - `review_wedged`: a green OPEN PR whose loop keeps blocking on review that
6//!     will not self-heal (the codex usage-limit lesson - surface it EARLY).
7//!   - `budget_stop`: a loop that terminated on `Budget` / `NoProgress` and
8//!     needs a human to re-arm.
9//!
10//! Unlike [`crate::digest`] (which folds ONE session's activity), this folds
11//! ALL sessions and emits at most one [`NeedItem`] per session - the worst
12//! reason its latest events imply. Each item is resolved to a node/name/title
13//! via the ledger bridge so the client can join it to a sideline row.
14//!
15//! The two event envelopes (Python nested `{"type","data":{...}}` and the
16//! retired Rust flat `{"kind",...}`) are both read, same as digest. Read-only:
17//! the verb writes nothing and is rerunnable at will.
18
19use crate::paths::AgentsHome;
20use serde::Serialize;
21use serde_json::Value;
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25/// Default fold window when `--since-epoch` is absent: the last 24h.
26const DEFAULT_WINDOW_SECS: u64 = 24 * 60 * 60;
27
28/// `fires` floor for `review_wedged`: the loop must have re-checked at least
29/// this many times before a green-PR block counts as wedged (a fresh block
30/// during a normal review wait is not yet a wedge). Hardcoded heuristic, not a
31/// config knob - tune the const if it misfires (ponytail: no config for a value
32/// that never changes); a hidden `--fires-floor` overrides it for tests.
33const DEFAULT_FIRES_FLOOR: u64 = 2;
34
35/// One reason a session needs a human, resolved and ready to render. `kind` is
36/// a stable string (`review_wedged` | `budget_stop`) the client maps to its own
37/// severity enum; the fold does not rank (the client owns the full 6-kind
38/// order, of which this leg populates two).
39#[derive(Debug, Clone, Serialize, PartialEq)]
40pub struct NeedItem {
41    pub kind: String,
42    pub session_id: String,
43    /// The graph node id, when the ledger resolves one.
44    pub node: Option<String>,
45    /// A display name to join a sideline row on (worktree basename or node id).
46    pub name: Option<String>,
47    pub title: Option<String>,
48    /// The deciding event's timestamp (the wedge's loop_check, or the stop).
49    pub ts: String,
50    /// A one-line human reason.
51    pub evidence: String,
52    /// Does this item's node hold a live (or suspect) claim? Stamped by the IO
53    /// layer, not the pure fold. The client renders an item that joins no roster
54    /// row only when it is `live`, so a dead session's stale stop never nags.
55    pub live: bool,
56}
57
58/// Read `<field>` regardless of envelope: nested under `/data` (unified) or
59/// top-level (retired flat). Mirrors [`crate::digest`] - kept local so this
60/// module stays a self-contained leaf (x-7fdd: no function-local cross-imports).
61fn field<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
62    v.get("data")
63        .and_then(|d| d.get(key))
64        .or_else(|| v.get(key))
65}
66
67fn event_kind(v: &Value) -> Option<&str> {
68    v.get("type")
69        .and_then(|t| t.as_str())
70        .or_else(|| v.get("kind").and_then(|k| k.as_str()))
71}
72
73fn event_ts(v: &Value) -> &str {
74    v.get("ts").and_then(|t| t.as_str()).unwrap_or("")
75}
76
77fn str_field<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
78    field(v, key).and_then(|f| f.as_str())
79}
80
81/// The basename of a `/`-separated path.
82fn basename(path: &str) -> &str {
83    path.rsplit('/').next().unwrap_or(path)
84}
85
86/// Parse an RFC3339-ish ts to epoch seconds, tolerating the ledger's non-strict
87/// (`...isoformat()`, no Z, fractional) forms. Same lenient parse as digest.
88fn to_epoch_lenient(ts: &str) -> Option<u64> {
89    crate::state::rfc3339_like_to_secs(ts).or_else(|| {
90        let secs = ts.get(..19)?;
91        crate::state::rfc3339_like_to_secs(&format!("{secs}Z"))
92    })
93}
94
95/// A row's ts is in-window when it parses to `>= since` (an unparseable ts is
96/// included - never silently dropped by the bound).
97fn in_window(ts: &str, since: u64) -> bool {
98    to_epoch_lenient(ts).is_none_or(|secs| secs >= since)
99}
100
101/// The latest loop_check state observed for a session. "Latest" is by
102/// `(epoch, seq)`, NOT file order: events from a project + global events.jsonl
103/// are concatenated, so a later-in-file line can be older in time; comparing by
104/// parsed epoch (with a monotonic fold `seq` tiebreak for same-second events)
105/// keeps the truly newest state per kind.
106#[derive(Default, Clone)]
107struct LoopState {
108    decision: String,
109    intent: String,
110    ci: String,
111    pr_state: String,
112    reviewed: bool,
113    fires: u64,
114    ts: String,
115    epoch: u64,
116    seq: usize,
117}
118
119/// The latest termination observed for a session (same `(epoch, seq)` ordering).
120#[derive(Default, Clone)]
121struct TermState {
122    reason: String,
123    ts: String,
124    epoch: u64,
125    seq: usize,
126}
127
128#[derive(Default)]
129struct SessionAcc {
130    latest_loop: Option<LoopState>,
131    latest_term: Option<TermState>,
132}
133
134/// The pure fold. `events_raw` is the newline-joined concatenation of every
135/// events.jsonl source; `ledger_raw` is ledger.json. Emits one [`NeedItem`] per
136/// qualifying session, sorted `(ts, session_id)` for deterministic output.
137pub fn fold(events_raw: &str, ledger_raw: &str, since: u64, fires_floor: u64) -> Vec<NeedItem> {
138    let mut sessions: HashMap<String, SessionAcc> = HashMap::new();
139    // Monotonic fold position, breaking same-second ties in true stream order.
140    let mut seq = 0usize;
141    // mail_escalation events carry no session_id (they are about mail between
142    // agents, not a target session), so they cannot enter the session-keyed
143    // accumulator and the session gate below would drop them. They get their own
144    // per-recipient accumulator (latest (epoch, seq) wins) and render
145    // squadless-live in the client.
146    let mut mail_escalations: HashMap<String, (u64, usize, NeedItem)> = HashMap::new();
147
148    for line in events_raw.lines() {
149        if line.trim().is_empty() {
150            continue;
151        }
152        let Ok(v) = serde_json::from_str::<Value>(line) else {
153            continue; // torn/malformed tail line: skip, never abort (digest precedent)
154        };
155        let ts = event_ts(&v);
156        if !in_window(ts, since) {
157            continue;
158        }
159        let kind = event_kind(&v);
160        // mail_escalation is folded before the session gate: it carries no
161        // session_id (it is mail between agents), so the gate below would drop
162        // it. One NeedItem (kind mail_question) per recipient, latest wins.
163        if kind == Some("mail_escalation") {
164            let Some(recipient) = str_field(&v, "recipient") else {
165                continue;
166            };
167            let reason = str_field(&v, "reason").unwrap_or("");
168            let sender = str_field(&v, "sender").unwrap_or("");
169            let summary = str_field(&v, "summary").unwrap_or("");
170            let epoch = to_epoch_lenient(ts).unwrap_or(0);
171            seq += 1;
172            // Same (epoch, seq) ordering as the session accumulator: a cross-source
173            // concat never lets an older line clobber a newer escalation.
174            if mail_escalations
175                .get(recipient)
176                .is_none_or(|(e, s, _)| (epoch, seq) >= (*e, *s))
177            {
178                mail_escalations.insert(
179                    recipient.to_string(),
180                    (
181                        epoch,
182                        seq,
183                        NeedItem {
184                            kind: "mail_question".to_string(),
185                            // No target session; the recipient handle is the row's
186                            // stable identity (id_key) and the roster join key.
187                            session_id: recipient.to_string(),
188                            node: None,
189                            name: Some(recipient.to_string()),
190                            title: None,
191                            ts: ts.to_string(),
192                            evidence: format!("{reason}: {sender} -> {recipient}: {summary}"),
193                            // Stamped always-live by stamp_liveness (no node claim).
194                            live: false,
195                        },
196                    ),
197                );
198            }
199            continue;
200        }
201        let Some(sid) = str_field(&v, "session_id") else {
202            continue; // an event with no session can't be joined to a row
203        };
204        if !matches!(
205            kind,
206            Some("loop_check") | Some("termination") | Some("loop_terminated")
207        ) {
208            continue;
209        }
210        let epoch = to_epoch_lenient(ts).unwrap_or(0);
211        seq += 1;
212        let acc = sessions.entry(sid.to_string()).or_default();
213        match kind {
214            Some("loop_check") => {
215                // Keep the newest by (epoch, seq): a later-in-file but older-in-
216                // time line (cross-source concat) never clobbers newer state.
217                if acc
218                    .latest_loop
219                    .as_ref()
220                    .is_none_or(|c| (epoch, seq) >= (c.epoch, c.seq))
221                {
222                    acc.latest_loop = Some(LoopState {
223                        decision: str_field(&v, "decision").unwrap_or("").to_string(),
224                        intent: str_field(&v, "intent").unwrap_or("").to_string(),
225                        ci: str_field(&v, "ci").unwrap_or("").to_string(),
226                        pr_state: str_field(&v, "pr_state").unwrap_or("").to_string(),
227                        reviewed: field(&v, "reviewed")
228                            .and_then(|r| r.as_bool())
229                            .unwrap_or(false),
230                        fires: field(&v, "fires").and_then(|f| f.as_u64()).unwrap_or(0),
231                        ts: ts.to_string(),
232                        epoch,
233                        seq,
234                    });
235                }
236            }
237            _ => {
238                if acc
239                    .latest_term
240                    .as_ref()
241                    .is_none_or(|c| (epoch, seq) >= (c.epoch, c.seq))
242                {
243                    acc.latest_term = Some(TermState {
244                        reason: str_field(&v, "reason").unwrap_or("").to_string(),
245                        ts: ts.to_string(),
246                        epoch,
247                        seq,
248                    });
249                }
250            }
251        }
252    }
253
254    let ledger = LedgerIndex::parse(ledger_raw);
255    let mut items: Vec<NeedItem> = Vec::new();
256    for (sid, acc) in &sessions {
257        if let Some((kind, ts, evidence)) = classify(acc, fires_floor) {
258            let (node, name, title) = ledger.resolve(sid);
259            items.push(NeedItem {
260                kind: kind.to_string(),
261                session_id: sid.clone(),
262                node,
263                name,
264                title,
265                ts,
266                evidence,
267                live: false, // stamped by the IO layer; the fold stays pure
268            });
269        }
270    }
271    for (_, (_, _, item)) in mail_escalations {
272        items.push(item);
273    }
274    items.sort_by(|a, b| {
275        a.ts.cmp(&b.ts)
276            .then_with(|| a.session_id.cmp(&b.session_id))
277    });
278    items
279}
280
281/// The reason a session's latest events imply, or `None` when nothing needs a
282/// human. Termination is terminal, so a session that ended on `Budget` /
283/// `NoProgress` is a `budget_stop`; any other termination (DonePRGreen, NoWork,
284/// Interrupted, ...) means nothing needs me. A still-live loop whose latest
285/// check is a green OPEN unreviewed block past the fires floor is `review_wedged`
286/// - a later `allow` or a termination clears it (the latest event wins).
287fn classify(acc: &SessionAcc, fires_floor: u64) -> Option<(&'static str, String, String)> {
288    // Order by (epoch, seq), the same key the accumulator kept: epoch first (so
289    // a fractional Python-isoformat stop is not misordered against a Z loop_check
290    // by a lexical string compare), then the monotonic fold seq so a same-second
291    // loop_check that RE-ARMS after a termination wins over the stop (codex P2).
292    let terminated = match (&acc.latest_term, &acc.latest_loop) {
293        (Some(t), Some(l)) => (t.epoch, t.seq) >= (l.epoch, l.seq),
294        (Some(_), None) => true,
295        (None, _) => false,
296    };
297    if terminated {
298        let t = acc.latest_term.as_ref()?;
299        return match t.reason.as_str() {
300            "Budget" | "NoProgress" => Some((
301                "budget_stop",
302                t.ts.clone(),
303                format!("loop stopped: {}", t.reason),
304            )),
305            _ => None,
306        };
307    }
308    let l = acc.latest_loop.as_ref()?;
309    // `intent == "promise"` is load-bearing: loopcheck Step 5 also blocks a
310    // still-WORKING session that has opened a green PR with `intent:"none"`
311    // (no promise, no backstop) - that is not wedged on review, it just has not
312    // promised yet. Only a promise-intent block on a green OPEN unreviewed PR
313    // that keeps re-firing is a review wedge (codex P2).
314    let wedged = l.decision == "block"
315        && l.intent == "promise"
316        && l.ci == "SUCCESS"
317        && l.pr_state == "OPEN"
318        && !l.reviewed
319        && l.fires >= fires_floor;
320    if wedged {
321        return Some((
322            "review_wedged",
323            l.ts.clone(),
324            format!("green PR wedged on review ({} checks)", l.fires),
325        ));
326    }
327    None
328}
329
330/// A minimal ledger index: maps a session id to its node/name/title. Reuses the
331/// digest bridge's match keys (scalar `session_id`, `sessions[]` membership,
332/// `graph_node_id`, `worktree`/`root_path` basename) but inverted - given a
333/// session, return its display identity.
334struct LedgerIndex {
335    entries: Vec<Value>,
336}
337
338impl LedgerIndex {
339    fn parse(ledger_raw: &str) -> Self {
340        let entries = serde_json::from_str::<Value>(ledger_raw)
341            .ok()
342            .and_then(|root| {
343                root.get("entries")
344                    .and_then(|e| e.as_array())
345                    .or_else(|| root.as_array())
346                    .cloned()
347            })
348            .unwrap_or_default();
349        LedgerIndex { entries }
350    }
351
352    fn entry_has_session(entry: &Value, sid: &str) -> bool {
353        if entry.get("session_id").and_then(|s| s.as_str()) == Some(sid) {
354            return true;
355        }
356        entry
357            .get("sessions")
358            .and_then(|s| s.as_array())
359            .is_some_and(|arr| arr.iter().any(|s| s.as_str() == Some(sid)))
360    }
361
362    /// `(node, name, title)` for a session. `name` prefers the worktree basename
363    /// (what a sideline orphan row carries), else the node id. All `None` when
364    /// unresolved - the client renders a session-id-only squadless row then.
365    fn resolve(&self, sid: &str) -> (Option<String>, Option<String>, Option<String>) {
366        let Some(entry) = self
367            .entries
368            .iter()
369            .find(|e| Self::entry_has_session(e, sid))
370        else {
371            return (None, None, None);
372        };
373        let node = entry
374            .get("graph_node_id")
375            .and_then(|v| v.as_str())
376            .map(str::to_string);
377        let title = entry
378            .get("title")
379            .and_then(|v| v.as_str())
380            .map(str::to_string);
381        let name = ["worktree", "root_path"]
382            .iter()
383            .find_map(|k| entry.get(*k).and_then(|v| v.as_str()))
384            .map(|p| basename(p).to_string())
385            .or_else(|| node.clone());
386        (node, name, title)
387    }
388}
389
390struct NeedsArgs {
391    since_epoch: Option<u64>,
392    fires_floor: u64,
393    json: bool,
394    events_override: Vec<PathBuf>,
395    ledger_override: Option<PathBuf>,
396}
397
398fn parse_args(rest: &[String]) -> Result<NeedsArgs, String> {
399    let mut since_epoch: Option<u64> = None;
400    let mut fires_floor = DEFAULT_FIRES_FLOOR;
401    let mut json = false;
402    let mut events_override: Vec<PathBuf> = Vec::new();
403    let mut ledger_override: Option<PathBuf> = None;
404
405    let mut it = expand_eq(rest).into_iter();
406    while let Some(a) = it.next() {
407        match a.as_str() {
408            "--since-epoch" => {
409                since_epoch = Some(
410                    it.next()
411                        .and_then(|v| v.parse::<u64>().ok())
412                        .ok_or("--since-epoch needs a non-negative integer")?,
413                )
414            }
415            "--fires-floor" => {
416                fires_floor = it
417                    .next()
418                    .and_then(|v| v.parse::<u64>().ok())
419                    .ok_or("--fires-floor needs a non-negative integer")?
420            }
421            "--json" | "-J" => json = true,
422            "--events" => {
423                events_override.push(PathBuf::from(it.next().ok_or("--events needs a path")?))
424            }
425            "--ledger" => {
426                ledger_override = Some(PathBuf::from(it.next().ok_or("--ledger needs a path")?))
427            }
428            other => return Err(format!("unknown needs flag: {other}")),
429        }
430    }
431    Ok(NeedsArgs {
432        since_epoch,
433        fires_floor,
434        json,
435        events_override,
436        ledger_override,
437    })
438}
439
440/// Split `--key=value` into `["--key","value"]`.
441fn expand_eq(rest: &[String]) -> Vec<String> {
442    let mut out = Vec::with_capacity(rest.len());
443    for a in rest {
444        if let Some(eq) = a.find('=') {
445            if a.starts_with("--") && eq > 2 {
446                out.push(a[..eq].to_string());
447                out.push(a[eq + 1..].to_string());
448                continue;
449            }
450        }
451        out.push(a.clone());
452    }
453    out
454}
455
456/// Default event/ledger sources: project `.fno/events.jsonl` + global
457/// `~/.fno/events.jsonl` + `~/.fno/ledger.json` (the digest layout).
458fn default_sources(home: &AgentsHome) -> (Vec<PathBuf>, PathBuf) {
459    let fno_dir = home
460        .root()
461        .parent()
462        .map(Path::to_path_buf)
463        .unwrap_or_else(|| PathBuf::from(".fno"));
464    let global_events = fno_dir.join("events.jsonl");
465    let project_events = PathBuf::from(".fno").join("events.jsonl");
466    let ledger = fno_dir.join("ledger.json");
467    (vec![project_events, global_events], ledger)
468}
469
470/// Stamp each item's `live` bit from its node claim (x-feec 1.4): an item whose
471/// node holds a Live or Suspect claim (a suspect TTL-unexpired claim still
472/// protects the slot) renders even without a roster row; an unclaimed or
473/// node-less one stays `live=false` and the client drops it when unjoined. This
474/// is the IO half of the fold, kept out of the pure [`fold`] so it stays testable.
475fn stamp_liveness(mut items: Vec<NeedItem>) -> Vec<NeedItem> {
476    for item in &mut items {
477        // A mail escalation is always-live by design: it carries no node claim
478        // (it is about mail between agents, not a target session), so the
479        // node-keyed stamp below would mark it dead and the client's
480        // squadless-render branch would drop it -- the silent eat this closes.
481        // The live bit here is an honest "surface this with no roster row"
482        // label, not a session-liveness claim.
483        if item.kind == "mail_question" {
484            item.live = true;
485            continue;
486        }
487        item.live = item.node.as_deref().is_some_and(|n| {
488            let (state, _) = crate::claims::status(&format!("node:{n}"), None);
489            matches!(
490                state,
491                crate::claims::ClaimState::Live | crate::claims::ClaimState::Suspect
492            )
493        });
494    }
495    items
496}
497
498/// Current epoch seconds; `0` if the clock is somehow before the epoch.
499fn now_secs() -> u64 {
500    std::time::SystemTime::now()
501        .duration_since(std::time::UNIX_EPOCH)
502        .map(|d| d.as_secs())
503        .unwrap_or(0)
504}
505
506/// The `fno-agents needs` verb. Read-only; exits 0 on empty/corrupt input (only
507/// a usage error exits 2), so the overlay caller never sees a failure it must
508/// handle beyond a nonzero exit.
509pub async fn run_needs(rest: &[String], home: &AgentsHome) -> i32 {
510    let args = match parse_args(rest) {
511        Ok(a) => a,
512        Err(msg) => {
513            eprintln!("fno-agents: {msg}");
514            return 2;
515        }
516    };
517
518    let (default_events, default_ledger) = default_sources(home);
519    let event_paths = if args.events_override.is_empty() {
520        default_events
521    } else {
522        args.events_override
523    };
524    let ledger_path = args.ledger_override.unwrap_or(default_ledger);
525
526    let mut events_raw = String::new();
527    for p in &event_paths {
528        if let Ok(content) = std::fs::read_to_string(p) {
529            events_raw.push_str(&content);
530            if !content.ends_with('\n') {
531                events_raw.push('\n');
532            }
533        }
534    }
535    let ledger_raw = std::fs::read_to_string(&ledger_path).unwrap_or_default();
536
537    let since = args
538        .since_epoch
539        .unwrap_or_else(|| now_secs().saturating_sub(DEFAULT_WINDOW_SECS));
540    let items = stamp_liveness(fold(&events_raw, &ledger_raw, since, args.fires_floor));
541
542    if args.json {
543        println!(
544            "{}",
545            serde_json::to_string(&items).expect("serializing an owned value never fails")
546        );
547    } else {
548        for item in &items {
549            let name = item.name.as_deref().unwrap_or(&item.session_id);
550            println!("{} {} - {}", item.kind, name, item.evidence);
551        }
552    }
553    0
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    // A promise-intent loop_check (the review-wedge case). Non-wedge tests care
561    // about the other fields, so a promise intent is the convenient default.
562    fn loop_check(
563        ts: &str,
564        session: &str,
565        decision: &str,
566        ci: &str,
567        pr_state: &str,
568        reviewed: bool,
569        fires: u64,
570    ) -> String {
571        loop_check_i(
572            ts, session, decision, "promise", ci, pr_state, reviewed, fires,
573        )
574    }
575
576    #[allow(clippy::too_many_arguments)]
577    fn loop_check_i(
578        ts: &str,
579        session: &str,
580        decision: &str,
581        intent: &str,
582        ci: &str,
583        pr_state: &str,
584        reviewed: bool,
585        fires: u64,
586    ) -> String {
587        format!(
588            r#"{{"ts":"{ts}","type":"loop_check","source":"hook","data":{{"session_id":"{session}","decision":"{decision}","intent":"{intent}","ci":"{ci}","pr_state":"{pr_state}","reviewed":{reviewed},"fires":{fires}}}}}"#
589        )
590    }
591
592    fn termination(ts: &str, session: &str, reason: &str) -> String {
593        format!(
594            r#"{{"ts":"{ts}","type":"termination","source":"hook","data":{{"session_id":"{session}","reason":"{reason}"}}}}"#
595        )
596    }
597
598    // The whole default window: since=0 lets every fixture ts through.
599    const ALL: u64 = 0;
600
601    #[test]
602    fn green_open_unreviewed_block_is_review_wedged() {
603        let events = loop_check(
604            "2026-07-03T02:00:00Z",
605            "s",
606            "block",
607            "SUCCESS",
608            "OPEN",
609            false,
610            5,
611        );
612        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
613        assert_eq!(items.len(), 1);
614        assert_eq!(items[0].kind, "review_wedged");
615        assert_eq!(items[0].session_id, "s");
616        assert!(items[0].evidence.contains("5 checks"));
617    }
618
619    #[test]
620    fn budget_termination_is_budget_stop() {
621        let events = termination("2026-07-03T02:00:00Z", "s", "Budget");
622        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
623        assert_eq!(items.len(), 1);
624        assert_eq!(items[0].kind, "budget_stop");
625        assert!(items[0].evidence.contains("Budget"));
626    }
627
628    #[test]
629    fn noprogress_termination_is_budget_stop() {
630        let events = termination("2026-07-03T02:00:00Z", "s", "NoProgress");
631        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
632        assert_eq!(items[0].kind, "budget_stop");
633    }
634
635    #[test]
636    fn done_pr_green_termination_yields_nothing() {
637        let events = termination("2026-07-03T02:00:00Z", "s", "DonePRGreen");
638        assert!(fold(&events, "", ALL, DEFAULT_FIRES_FLOOR).is_empty());
639    }
640
641    fn mail_escalation(
642        ts: &str,
643        reason: &str,
644        sender: &str,
645        recipient: &str,
646        summary: &str,
647    ) -> String {
648        format!(
649            r#"{{"ts":"{ts}","type":"mail_escalation","source":"target","data":{{"reason":"{reason}","sender":"{sender}","recipient":"{recipient}","summary":"{summary}"}}}}"#
650        )
651    }
652
653    #[test]
654    fn mail_escalation_folds_to_mail_question_without_a_session() {
655        // The trap this node closes: a mail_escalation carries no session_id, so
656        // the session gate and the loop_check kind filter would both drop it. The
657        // fold arm handles it before the gate and emits a mail_question row keyed
658        // by recipient (the row identity, not a target session).
659        let events = mail_escalation(
660            "2026-07-03T02:00:00Z",
661            "question",
662            "etl",
663            "web",
664            "which schema?",
665        );
666        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
667        assert_eq!(items.len(), 1);
668        assert_eq!(items[0].kind, "mail_question");
669        assert_eq!(items[0].name.as_deref(), Some("web"));
670        assert_eq!(items[0].session_id, "web");
671        assert!(items[0].evidence.contains("question"));
672        assert!(items[0].evidence.contains("etl -> web"));
673    }
674
675    #[test]
676    fn mail_escalation_is_stamped_always_live_with_no_node() {
677        // No node claim -> the node-keyed stamp would mark it dead and the
678        // client's squadless-render branch would drop it. stamp_liveness marks
679        // mail_question always-live so it renders with no roster row.
680        let events = mail_escalation(
681            "2026-07-03T02:00:00Z",
682            "attended-miss",
683            "ops",
684            "claude-9a06",
685            "need you",
686        );
687        let items = stamp_liveness(fold(&events, "", ALL, DEFAULT_FIRES_FLOOR));
688        assert_eq!(items.len(), 1);
689        assert_eq!(items[0].node, None);
690        assert!(
691            items[0].live,
692            "mail_question is always-live even with no node"
693        );
694    }
695
696    #[test]
697    fn mail_escalation_latest_per_recipient_wins() {
698        let events = format!(
699            "{}\n{}\n",
700            mail_escalation("2026-07-03T02:00:00Z", "question", "etl", "web", "old"),
701            mail_escalation("2026-07-03T03:00:00Z", "attended-miss", "ops", "web", "new"),
702        );
703        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
704        assert_eq!(items.len(), 1, "one row per recipient");
705        assert!(
706            items[0].evidence.contains("new"),
707            "latest (epoch, seq) wins"
708        );
709    }
710
711    #[test]
712    fn same_second_rearm_after_termination_is_not_terminated() {
713        // A Budget stop then a same-second re-armed loop_check: the loop's higher
714        // fold seq wins the (epoch, seq) tiebreak, so the session reads as live
715        // again (review_wedged), not budget-stopped (codex P2).
716        let events = [
717            termination("2026-07-03T02:00:00Z", "s", "Budget"),
718            loop_check(
719                "2026-07-03T02:00:00Z",
720                "s",
721                "block",
722                "SUCCESS",
723                "OPEN",
724                false,
725                9,
726            ),
727        ]
728        .join("\n");
729        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
730        assert_eq!(items.len(), 1);
731        assert_eq!(items[0].kind, "review_wedged");
732    }
733
734    #[test]
735    fn newer_state_survives_older_line_from_a_later_source() {
736        // Simulate project+global concat where the LATER-in-file line is OLDER:
737        // an old allow appended after a newer block must not clobber the block.
738        let events = [
739            loop_check(
740                "2026-07-03T05:00:00Z",
741                "s",
742                "block",
743                "SUCCESS",
744                "OPEN",
745                false,
746                9,
747            ),
748            loop_check(
749                "2026-07-03T01:00:00Z",
750                "s",
751                "allow",
752                "SUCCESS",
753                "OPEN",
754                true,
755                3,
756            ),
757        ]
758        .join("\n");
759        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
760        assert_eq!(
761            items.len(),
762            1,
763            "the newer block state survives the older line"
764        );
765        assert_eq!(items[0].kind, "review_wedged");
766    }
767
768    #[test]
769    fn intent_none_block_is_not_wedged() {
770        // A still-WORKING session that opened a green OPEN PR blocks with
771        // intent:none (no promise yet); it is not wedged on review (codex P2).
772        let events = loop_check_i(
773            "2026-07-03T02:00:00Z",
774            "s",
775            "block",
776            "none",
777            "SUCCESS",
778            "OPEN",
779            false,
780            9,
781        );
782        assert!(fold(&events, "", ALL, DEFAULT_FIRES_FLOOR).is_empty());
783    }
784
785    #[test]
786    fn merged_pr_block_is_not_wedged() {
787        // The real-data false positive: a MERGED PR whose loop still fires is
788        // done, not wedged on review. pr_state OPEN gate excludes it.
789        let events = loop_check(
790            "2026-07-03T02:00:00Z",
791            "s",
792            "block",
793            "SUCCESS",
794            "MERGED",
795            false,
796            144,
797        );
798        assert!(fold(&events, "", ALL, DEFAULT_FIRES_FLOOR).is_empty());
799    }
800
801    #[test]
802    fn later_allow_clears_the_wedge() {
803        let events = [
804            loop_check(
805                "2026-07-03T02:00:00Z",
806                "s",
807                "block",
808                "SUCCESS",
809                "OPEN",
810                false,
811                5,
812            ),
813            loop_check(
814                "2026-07-03T03:00:00Z",
815                "s",
816                "allow",
817                "SUCCESS",
818                "OPEN",
819                true,
820                5,
821            ),
822        ]
823        .join("\n");
824        assert!(fold(&events, "", ALL, DEFAULT_FIRES_FLOOR).is_empty());
825    }
826
827    #[test]
828    fn termination_after_wedge_wins() {
829        // A green-block session that then terminates on DonePRGreen is done.
830        let events = [
831            loop_check(
832                "2026-07-03T02:00:00Z",
833                "s",
834                "block",
835                "SUCCESS",
836                "OPEN",
837                false,
838                5,
839            ),
840            termination("2026-07-03T03:00:00Z", "s", "DonePRGreen"),
841        ]
842        .join("\n");
843        assert!(fold(&events, "", ALL, DEFAULT_FIRES_FLOOR).is_empty());
844    }
845
846    #[test]
847    fn wedge_after_a_stale_budget_stop_reads_as_wedge() {
848        // A budget stop followed by a fresh loop (re-armed) is live again.
849        let events = [
850            termination("2026-07-03T02:00:00Z", "s", "Budget"),
851            loop_check(
852                "2026-07-03T03:00:00Z",
853                "s",
854                "block",
855                "SUCCESS",
856                "OPEN",
857                false,
858                9,
859            ),
860        ]
861        .join("\n");
862        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
863        assert_eq!(items[0].kind, "review_wedged");
864    }
865
866    #[test]
867    fn termination_with_fractional_ts_still_wins_over_z_loop_check() {
868        // Lexically ".5" < "Z", so a same-second fractional termination would
869        // sort BEFORE the loop_check and misclassify a real stop; epoch compare
870        // fixes it (gemini HIGH finding).
871        let events = [
872            loop_check(
873                "2026-07-03T02:00:00Z",
874                "s",
875                "block",
876                "SUCCESS",
877                "OPEN",
878                false,
879                5,
880            ),
881            termination("2026-07-03T02:00:00.5", "s", "Budget"),
882        ]
883        .join("\n");
884        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
885        assert_eq!(items.len(), 1);
886        assert_eq!(
887            items[0].kind, "budget_stop",
888            "the termination wins despite its fractional ts"
889        );
890    }
891
892    #[test]
893    fn fires_below_floor_is_not_wedged() {
894        let events = loop_check(
895            "2026-07-03T02:00:00Z",
896            "s",
897            "block",
898            "SUCCESS",
899            "OPEN",
900            false,
901            1,
902        );
903        assert!(fold(&events, "", ALL, 2).is_empty());
904    }
905
906    #[test]
907    fn since_window_excludes_old_events() {
908        let events = loop_check(
909            "2026-07-03T02:00:00Z",
910            "s",
911            "block",
912            "SUCCESS",
913            "OPEN",
914            false,
915            5,
916        );
917        let future = crate::state::rfc3339_like_to_secs("2099-01-01T00:00:00Z").unwrap();
918        assert!(fold(&events, "", future, DEFAULT_FIRES_FLOOR).is_empty());
919    }
920
921    #[test]
922    fn malformed_line_is_skipped_not_aborted() {
923        let events = [
924            "{ this is not valid json".to_string(),
925            loop_check(
926                "2026-07-03T02:00:00Z",
927                "s",
928                "block",
929                "SUCCESS",
930                "OPEN",
931                false,
932                5,
933            ),
934        ]
935        .join("\n");
936        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
937        assert_eq!(items.len(), 1, "the good line still folds");
938    }
939
940    #[test]
941    fn one_item_per_session_latest_wins() {
942        // Two sessions, each with a distinct reason.
943        let events = [
944            loop_check(
945                "2026-07-03T02:00:00Z",
946                "a",
947                "block",
948                "SUCCESS",
949                "OPEN",
950                false,
951                5,
952            ),
953            termination("2026-07-03T02:30:00Z", "b", "Budget"),
954        ]
955        .join("\n");
956        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
957        assert_eq!(items.len(), 2);
958        // Sorted by ts: the wedge (02:00) before the budget stop (02:30).
959        assert_eq!(items[0].kind, "review_wedged");
960        assert_eq!(items[1].kind, "budget_stop");
961    }
962
963    #[test]
964    fn ledger_resolves_node_name_title() {
965        let events = loop_check(
966            "2026-07-03T02:00:00Z",
967            "sess-x",
968            "block",
969            "SUCCESS",
970            "OPEN",
971            false,
972            5,
973        );
974        let ledger = r#"{"entries":[{"session_id":"sess-x","graph_node_id":"x-feec","title":"needs queue","worktree":"/w/footnote/x-feec"}]}"#;
975        let items = fold(&events, ledger, ALL, DEFAULT_FIRES_FLOOR);
976        assert_eq!(items[0].node.as_deref(), Some("x-feec"));
977        assert_eq!(items[0].name.as_deref(), Some("x-feec"));
978        assert_eq!(items[0].title.as_deref(), Some("needs queue"));
979    }
980
981    #[test]
982    fn ledger_resolves_via_sessions_array() {
983        let events = termination("2026-07-03T02:00:00Z", "fno-sess", "Budget");
984        let ledger = r#"[{"sessions":["uuid-1","fno-sess"],"graph_node_id":"x-1","worktree":"/w/footnote/x-1"}]"#;
985        let items = fold(&events, ledger, ALL, DEFAULT_FIRES_FLOOR);
986        assert_eq!(items[0].node.as_deref(), Some("x-1"));
987    }
988
989    #[test]
990    fn unresolved_session_renders_id_only() {
991        let events = termination("2026-07-03T02:00:00Z", "ghost", "Budget");
992        let items = fold(&events, "", ALL, DEFAULT_FIRES_FLOOR);
993        assert_eq!(items[0].node, None);
994        assert_eq!(items[0].name, None);
995        assert_eq!(items[0].session_id, "ghost");
996    }
997}