1use crate::paths::AgentsHome;
20use serde::Serialize;
21use serde_json::Value;
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25const DEFAULT_WINDOW_SECS: u64 = 24 * 60 * 60;
27
28const DEFAULT_FIRES_FLOOR: u64 = 2;
34
35#[derive(Debug, Clone, Serialize, PartialEq)]
40pub struct NeedItem {
41 pub kind: String,
42 pub session_id: String,
43 pub node: Option<String>,
45 pub name: Option<String>,
47 pub title: Option<String>,
48 pub ts: String,
50 pub evidence: String,
52 pub live: bool,
56}
57
58fn 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
81fn basename(path: &str) -> &str {
83 path.rsplit('/').next().unwrap_or(path)
84}
85
86fn 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
95fn in_window(ts: &str, since: u64) -> bool {
98 to_epoch_lenient(ts).is_none_or(|secs| secs >= since)
99}
100
101#[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#[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
134pub 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 let mut seq = 0usize;
141 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; };
155 let ts = event_ts(&v);
156 if !in_window(ts, since) {
157 continue;
158 }
159 let kind = event_kind(&v);
160 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 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 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 live: false,
195 },
196 ),
197 );
198 }
199 continue;
200 }
201 let Some(sid) = str_field(&v, "session_id") else {
202 continue; };
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 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, });
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
281fn classify(acc: &SessionAcc, fires_floor: u64) -> Option<(&'static str, String, String)> {
288 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 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
330struct 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 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
440fn 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
456fn 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
470fn stamp_liveness(mut items: Vec<NeedItem>) -> Vec<NeedItem> {
476 for item in &mut items {
477 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
498fn 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
506pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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}