1use std::collections::BTreeMap;
30use std::process::Command;
31
32use serde_json::json;
33
34use crate::events::EventEmitter;
35use crate::manifest::{load_manifest, AnswerablePrompt, Manifest, Verdict};
36use crate::paths::AgentsHome;
37use crate::readiness::ScreenView;
38use crate::state::{self, Registry, ScreenStateReport};
39use crate::AgentStatus;
40
41pub const SCREEN_STATE_TTL_MS: u64 = 120_000;
46
47const REFRESH_AFTER_SECS: u64 = SCREEN_STATE_TTL_MS / 2 / 1000;
50
51#[derive(Debug, Clone, PartialEq)]
53pub struct ScrapeTarget {
54 pub name: String,
55 pub provider: String,
56 pub session: String,
57 pub pane_id: u64,
58 pub last: Option<ScreenStateReport>,
59}
60
61pub fn scrape_targets(reg: &Registry) -> Vec<ScrapeTarget> {
64 reg.entries
65 .iter()
66 .filter(|e| {
67 e.inside_leg.is_none()
75 && !matches!(
76 e.status,
77 AgentStatus::Exited
78 | AgentStatus::PermanentDead
79 | AgentStatus::Orphaned
80 | AgentStatus::Failed
81 )
82 })
83 .filter_map(|e| {
84 e.mux.as_ref().map(|m| ScrapeTarget {
85 name: e.name.clone(),
86 provider: e.harness_name().to_string(),
87 session: m.session.clone(),
88 pane_id: m.pane_id,
89 last: e.screen_state.clone(),
90 })
91 })
92 .collect()
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub enum Decision {
98 Hold,
100 Clear,
102 Write(ScreenStateReport),
104}
105
106fn stamp_due_refresh(last: &ScreenStateReport, now_secs: u64) -> bool {
109 match state::rfc3339_like_to_secs(&last.at) {
110 Some(at) => now_secs.saturating_sub(at) > REFRESH_AFTER_SECS,
111 None => true,
112 }
113}
114
115pub fn decide(
127 last: Option<&ScreenStateReport>,
128 verdict: Option<Verdict<'_>>,
129 answerable: Option<AnswerablePrompt>,
130 now_secs: u64,
131 now_stamp: &str,
132) -> Decision {
133 let Some(v) = verdict else {
134 return Decision::Hold;
135 };
136 if v.skip_state_update {
137 return match last {
138 Some(l) if stamp_due_refresh(l, now_secs) => Decision::Write(ScreenStateReport {
141 at: now_stamp.to_string(),
142 seq: l.seq + 1,
143 ..l.clone()
144 }),
145 _ => Decision::Hold,
146 };
147 }
148 match last {
149 Some(l)
153 if l.state == v.state
154 && l.answerable == answerable
155 && !stamp_due_refresh(l, now_secs) =>
156 {
157 Decision::Hold
158 }
159 _ => Decision::Write(ScreenStateReport {
160 state: v.state.to_string(),
161 rule: v.rule_id.to_string(),
162 seq: last.map_or(1, |l| l.seq + 1),
163 at: now_stamp.to_string(),
164 ttl_ms: Some(SCREEN_STATE_TTL_MS),
165 answerable,
166 }),
167 }
168}
169
170fn fno_bin() -> std::ffi::OsString {
175 std::env::var_os("FNO_BIN").unwrap_or_else(|| std::ffi::OsString::from("fno"))
176}
177
178fn mux_pane_ls(bin: &std::ffi::OsStr, session: &str) -> Option<BTreeMap<u64, Option<String>>> {
188 let out = Command::new(bin)
189 .args(["mux", "pane", "ls", "--session", session, "--json"])
190 .output()
191 .ok()?;
192 if !out.status.success() {
193 return None;
194 }
195 let panes: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout).ok()?;
196 Some(
197 panes
198 .iter()
199 .filter_map(|p| {
200 Some((
201 p.get("pane_id")?.as_u64()?,
202 p.get("title").and_then(|t| t.as_str()).map(String::from),
203 ))
204 })
205 .collect(),
206 )
207}
208
209fn mux_pane_read(bin: &std::ffi::OsStr, session: &str, pane: u64) -> Option<String> {
212 let out = Command::new(bin)
213 .args([
214 "mux",
215 "pane",
216 "read",
217 &pane.to_string(),
218 "--session",
219 session,
220 "--json",
221 ])
222 .output()
223 .ok()?;
224 if !out.status.success() {
225 return None;
226 }
227 let reply: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
228 reply.get("text")?.as_str().map(String::from)
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
234enum WriteDisposition {
235 HookFlip,
238 Apply,
240 Skip,
244}
245
246fn write_disposition(e: &state::RegistryEntry, expect_ref: &(String, u64)) -> WriteDisposition {
249 if e.inside_leg.is_some() {
250 return WriteDisposition::HookFlip;
251 }
252 let cur_ref = e.mux.as_ref().map(|m| (m.session.clone(), m.pane_id));
253 if cur_ref.as_ref() == Some(expect_ref) {
254 WriteDisposition::Apply
255 } else {
256 WriteDisposition::Skip
257 }
258}
259
260pub fn scrape_sweep(home: &AgentsHome, emitter: &EventEmitter, notify_on_blocked: bool) {
269 let Ok(reg) = state::load_registry(&home.registry_json()) else {
270 return;
271 };
272 let targets = scrape_targets(®);
273 if targets.is_empty() {
274 return;
275 }
276 let bin = fno_bin();
277 let override_dir = home.manifests_dir();
278 let now_secs = std::time::SystemTime::now()
279 .duration_since(std::time::UNIX_EPOCH)
280 .unwrap_or_default()
281 .as_secs();
282 let now_stamp = crate::daemon::now_rfc3339_like();
283
284 let mut manifests: BTreeMap<String, Option<Manifest>> = BTreeMap::new();
287 let mut sessions: BTreeMap<String, Option<BTreeMap<u64, Option<String>>>> = BTreeMap::new();
288
289 let mut changes: Vec<(String, (String, u64), Option<ScreenStateReport>)> = Vec::new();
293 for t in &targets {
294 let manifest = manifests.entry(t.provider.clone()).or_insert_with(|| {
295 match load_manifest(&t.provider, Some(&override_dir)) {
296 Some(Ok(m)) => Some(m),
297 Some(Err(e)) => {
298 let _ = emitter.emit(
301 "screen_state_change",
302 &json!({"provider": t.provider, "error": e.to_string()}),
303 );
304 None
305 }
306 None => None,
308 }
309 });
310 let Some(manifest) = manifest else {
311 continue;
312 };
313 let panes = sessions
314 .entry(t.session.clone())
315 .or_insert_with(|| mux_pane_ls(&bin, &t.session));
316 let evidence = panes
317 .as_ref()
318 .and_then(|p| p.get(&t.pane_id))
319 .map(|title| (title.clone(), mux_pane_read(&bin, &t.session, t.pane_id)));
320 let decision = match evidence {
321 None | Some((_, None)) => {
324 if t.last.is_some() {
325 Decision::Clear
326 } else {
327 Decision::Hold
328 }
329 }
330 Some((title, Some(text))) => {
331 let view = ScreenView {
332 visible_text: &text,
333 cursor_row: 0,
336 cursor_col: 0,
337 osc_title: title.as_deref(),
338 osc_progress: None,
341 };
342 let (verdict, answerable) = match manifest.evaluate_answerable(&view) {
343 Some((v, a)) => (Some(v), a),
344 None => (None, None),
345 };
346 decide(t.last.as_ref(), verdict, answerable, now_secs, &now_stamp)
347 }
348 };
349 let expect_ref = (t.session.clone(), t.pane_id);
350 match decision {
351 Decision::Hold => {}
352 Decision::Clear => changes.push((t.name.clone(), expect_ref, None)),
353 Decision::Write(rep) => changes.push((t.name.clone(), expect_ref, Some(rep))),
354 }
355 }
356 if changes.is_empty() {
357 return;
358 }
359 let mut blocked_notifs: Vec<(String, String)> = Vec::new();
363 let write = state::update_registry(&home.registry_json(), |r| {
364 for (name, expect_ref, rep) in &changes {
365 if let Some(e) = r.find_mut(name) {
366 match write_disposition(e, expect_ref) {
367 WriteDisposition::HookFlip => e.screen_state = None,
368 WriteDisposition::Apply => {
369 if notify_on_blocked {
370 if let Some(new_rep) = rep {
371 let prev_blocked = e
372 .screen_state
373 .as_ref()
374 .is_some_and(|s| s.state == "blocked");
375 if new_rep.state == "blocked" && !prev_blocked {
376 blocked_notifs.push((name.clone(), new_rep.rule.clone()));
377 }
378 }
379 }
380 e.screen_state = rep.clone();
381 }
382 WriteDisposition::Skip => {}
383 }
384 }
385 }
386 });
387 if write.is_err() {
388 return; }
390 for (name, rule) in blocked_notifs {
391 crate::daemon::notify_transition(name, rule);
392 }
393 for (name, _, rep) in &changes {
394 let _ = emitter.emit(
395 "screen_state_change",
396 &json!({
397 "name": name,
398 "state": rep.as_ref().map(|r| r.state.clone()),
399 "rule": rep.as_ref().map(|r| r.rule.clone()),
400 "seq": rep.as_ref().map(|r| r.seq),
401 "cleared": rep.is_none(),
402 }),
403 );
404 }
405}
406
407pub fn run_detect(args: &[String]) -> i32 {
414 let (Some("explain"), Some(name)) = (args.first().map(String::as_str), args.get(1)) else {
415 eprintln!("usage: fno-agents detect explain <agent>");
416 return 2;
417 };
418 let home = AgentsHome::from_env();
419 let reg = match state::load_registry(&home.registry_json()) {
420 Ok(r) => r,
421 Err(e) => {
422 eprintln!("fno-agents: detect: registry read failed: {e}");
423 return 1;
424 }
425 };
426 let Some(entry) = reg.find(name) else {
427 eprintln!("fno-agents: detect: no such agent: {name}");
428 return 1;
429 };
430 let now_secs = std::time::SystemTime::now()
431 .duration_since(std::time::UNIX_EPOCH)
432 .unwrap_or_default()
433 .as_secs();
434 let age = |stamp: &str| -> String {
435 match state::rfc3339_like_to_secs(stamp) {
436 Some(at) => format!("{}s", now_secs.saturating_sub(at)),
437 None => format!("unparseable stamp {stamp:?}"),
438 }
439 };
440 println!("agent: {} (provider {})", entry.name, entry.harness_name());
441 if matches!(
444 entry.status,
445 AgentStatus::Exited | AgentStatus::PermanentDead
446 ) {
447 println!("authority: pane-exit (status {:?})", entry.status);
448 } else if let Some(leg) = &entry.inside_leg {
449 if leg.is_live_at(now_secs) {
450 println!(
451 "authority: hook (inside-leg report: state {:?}, seq {}, age {})",
452 leg.state,
453 leg.seq,
454 age(&leg.received_at)
455 );
456 } else {
457 println!(
458 "authority: liveness (hook report lapsed: seq {}, age {}; \
459 row is hook-capable so the screen-manifest rung stays off)",
460 leg.seq,
461 age(&leg.received_at)
462 );
463 }
464 } else if let Some(ss) = &entry.screen_state {
465 if ss.is_live_at(now_secs) {
466 println!(
467 "authority: screen-manifest (rule {:?} -> state {:?}, seq {}, age {}, ttl {:?}ms)",
468 ss.rule,
469 ss.state,
470 ss.seq,
471 age(&ss.at),
472 ss.ttl_ms
473 );
474 } else {
475 println!(
476 "authority: liveness (scrape verdict lapsed: rule {:?}, age {})",
477 ss.rule,
478 age(&ss.at)
479 );
480 }
481 } else {
482 println!("authority: liveness (no hook report, no scrape verdict)");
483 }
484 0
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::manifest::Manifest;
491
492 fn rep(state: &str, at: &str, seq: u64) -> ScreenStateReport {
493 ScreenStateReport {
494 state: state.into(),
495 rule: "r".into(),
496 seq,
497 at: at.into(),
498 ttl_ms: Some(SCREEN_STATE_TTL_MS),
499 answerable: None,
500 }
501 }
502
503 fn entry(name: &str, provider: &str) -> state::RegistryEntry {
504 state::RegistryEntry {
505 name: name.into(),
506 short_id: String::new(),
507 legacy_provider: provider.into(),
508 harness: None,
509 harness_session_id: None,
510 cwd: "/tmp/x".into(),
511 project_root: String::new(),
512 session_id: None,
513 legacy_claude_short_id: None,
514 claude_session_uuid: None,
515 messaging_socket_path: None,
516 codex_session_id: None,
517 gemini_session_id: None,
518 mcp_channel_id: None,
519 host_mode: None,
520 cc_session_id: None,
521 status: AgentStatus::Live,
522 last_message_at: None,
523 created_at: "2026-07-02T00:00:00Z".into(),
524 pid: None,
525 pid_start_time: None,
526 log_path: None,
527 last_reconciled_at: None,
528 inside_leg: None,
529 exited_at: None,
530 mux: None,
531 screen_state: None,
532 crown_level: None,
533 crown_scope: None,
534 crown_grantor: None,
535 }
536 }
537
538 const NOW_STAMP: &str = "2026-07-02T00:10:00Z";
539 fn now_secs() -> u64 {
540 state::rfc3339_like_to_secs(NOW_STAMP).unwrap()
541 }
542
543 #[test]
546 fn scrape_targets_selects_only_hookless_live_mux_rows() {
547 let mut reg = Registry::default();
548 let mut ok = entry("scrapeme", "codex");
550 ok.mux = Some(state::MuxRef {
551 session: "main".into(),
552 pane_id: 7,
553 });
554 reg.entries.push(ok);
555 let mut hooked = entry("hooked", "claude");
557 hooked.mux = Some(state::MuxRef {
558 session: "main".into(),
559 pane_id: 8,
560 });
561 hooked.inside_leg = Some(state::InsideLegReport {
562 state: state::InsideLegState::Working,
563 seq: 1,
564 reason: None,
565 received_at: "2020-01-01T00:00:00Z".into(),
566 ttl_ms: Some(1),
567 });
568 reg.entries.push(hooked);
569 reg.entries.push(entry("worker", "codex"));
571 for (i, status) in [
574 AgentStatus::Exited,
575 AgentStatus::PermanentDead,
576 AgentStatus::Orphaned,
577 AgentStatus::Failed,
578 ]
579 .into_iter()
580 .enumerate()
581 {
582 let mut dead = entry(&format!("dead{i}"), "codex");
583 dead.mux = Some(state::MuxRef {
584 session: "main".into(),
585 pane_id: 90 + i as u64,
586 });
587 dead.status = status;
588 reg.entries.push(dead);
589 }
590
591 let targets = scrape_targets(®);
592 assert_eq!(targets.len(), 1);
593 assert_eq!(targets[0].name, "scrapeme");
594 assert_eq!(targets[0].session, "main");
595 assert_eq!(targets[0].pane_id, 7);
596 }
597
598 #[test]
599 fn write_disposition_rechecks_hook_flip_and_mux_ref() {
600 let scraped = ("main".to_string(), 7u64);
603
604 let mut row = entry("r", "codex");
606 row.mux = Some(state::MuxRef {
607 session: "main".into(),
608 pane_id: 7,
609 });
610 assert_eq!(write_disposition(&row, &scraped), WriteDisposition::Apply);
611
612 row.inside_leg = Some(state::InsideLegReport {
614 state: state::InsideLegState::Working,
615 seq: 1,
616 reason: None,
617 received_at: NOW_STAMP.into(),
618 ttl_ms: None,
619 });
620 assert_eq!(
621 write_disposition(&row, &scraped),
622 WriteDisposition::HookFlip
623 );
624
625 let mut rehomed = entry("r", "codex");
628 rehomed.mux = Some(state::MuxRef {
629 session: "main".into(),
630 pane_id: 8,
631 });
632 assert_eq!(
633 write_disposition(&rehomed, &scraped),
634 WriteDisposition::Skip
635 );
636
637 let mut unhosted = entry("r", "codex");
639 unhosted.mux = None;
640 assert_eq!(
641 write_disposition(&unhosted, &scraped),
642 WriteDisposition::Skip
643 );
644 }
645
646 fn verdict_from<'m>(manifest: &'m Manifest, text: &str) -> Option<Verdict<'m>> {
649 let view = ScreenView {
650 visible_text: text,
651 cursor_row: 0,
652 cursor_col: 0,
653 osc_title: None,
654 osc_progress: None,
655 };
656 manifest.evaluate(&view)
657 }
658
659 fn one_rule_manifest(state: &str, needle: &str, skip: bool) -> Manifest {
660 Manifest::parse(&format!(
661 "[[rule]]\nid = \"r\"\nstate = \"{state}\"\npriority = 100\n\
662 region = \"whole_recent\"\nskip_state_update = {skip}\n\
663 gate = {{ contains = \"{needle}\" }}\n"
664 ))
665 .unwrap()
666 }
667
668 #[test]
669 fn decide_first_verdict_writes_seq_one_with_ttl() {
670 let m = one_rule_manifest("working", "esc to interrupt", false);
671 let d = decide(
672 None,
673 verdict_from(&m, "esc to interrupt"),
674 None,
675 now_secs(),
676 NOW_STAMP,
677 );
678 let Decision::Write(rep) = d else {
679 panic!("expected Write, got {d:?}");
680 };
681 assert_eq!(rep.state, "working");
682 assert_eq!(rep.rule, "r");
683 assert_eq!(rep.seq, 1);
684 assert_eq!(rep.at, NOW_STAMP);
685 assert_eq!(rep.ttl_ms, Some(SCREEN_STATE_TTL_MS));
686 }
687
688 #[test]
689 fn decide_unchanged_fresh_verdict_holds_no_churn() {
690 let m = one_rule_manifest("working", "busy", false);
692 let last = rep("working", "2026-07-02T00:09:50Z", 4);
693 let d = decide(
694 Some(&last),
695 verdict_from(&m, "busy"),
696 None,
697 now_secs(),
698 NOW_STAMP,
699 );
700 assert_eq!(d, Decision::Hold);
701 }
702
703 #[test]
704 fn decide_unchanged_stale_stamp_refreshes() {
705 let m = one_rule_manifest("working", "busy", false);
708 let last = rep("working", "2026-07-02T00:00:00Z", 4);
709 let d = decide(
710 Some(&last),
711 verdict_from(&m, "busy"),
712 None,
713 now_secs(),
714 NOW_STAMP,
715 );
716 let Decision::Write(new) = d else {
717 panic!("expected refresh Write, got {d:?}");
718 };
719 assert_eq!(new.state, "working");
720 assert_eq!(new.seq, 5);
721 assert_eq!(new.at, NOW_STAMP);
722 }
723
724 #[test]
725 fn decide_state_change_writes_immediately() {
726 let m = one_rule_manifest("blocked", "Do you want to proceed?", false);
727 let last = rep("working", NOW_STAMP, 2);
728 let d = decide(
729 Some(&last),
730 verdict_from(&m, "Do you want to proceed?"),
731 None,
732 now_secs(),
733 NOW_STAMP,
734 );
735 let Decision::Write(new) = d else {
736 panic!("expected Write, got {d:?}");
737 };
738 assert_eq!(new.state, "blocked");
739 assert_eq!(new.seq, 3);
740 }
741
742 #[test]
743 fn decide_no_match_holds_engine_never_guesses() {
744 let m = one_rule_manifest("working", "busy", false);
745 let last = rep("idle", NOW_STAMP, 1);
746 assert_eq!(
747 decide(
748 Some(&last),
749 verdict_from(&m, "nothing here"),
750 None,
751 now_secs(),
752 NOW_STAMP
753 ),
754 Decision::Hold
755 );
756 assert_eq!(
757 decide(
758 None,
759 verdict_from(&m, "nothing here"),
760 None,
761 now_secs(),
762 NOW_STAMP
763 ),
764 Decision::Hold
765 );
766 }
767
768 #[test]
774 fn run_detect_explain_exit_codes() {
775 let _guard = crate::claims::test_env_lock()
776 .lock()
777 .unwrap_or_else(|p| p.into_inner());
778 let dir = std::env::temp_dir().join(format!("fno-detect-{}", std::process::id()));
779 let _ = std::fs::remove_dir_all(&dir);
780 std::fs::create_dir_all(&dir).unwrap();
781 std::env::set_var("FNO_AGENTS_HOME", &dir);
782 let home = AgentsHome::from_env();
783 let mut scraped = entry("scrapee", "codex");
784 scraped.screen_state = Some(rep("idle", "2026-07-02T00:00:00Z", 1));
785 state::update_registry(&home.registry_json(), |r| r.entries.push(scraped)).unwrap();
786
787 let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
788 assert_eq!(run_detect(&s(&[])), 2, "missing op is a usage error");
789 assert_eq!(run_detect(&s(&["explain"])), 2, "missing agent name");
790 assert_eq!(run_detect(&s(&["explain", "ghost"])), 1, "unknown agent");
791 assert_eq!(run_detect(&s(&["explain", "scrapee"])), 0);
792
793 std::env::remove_var("FNO_AGENTS_HOME");
794 let _ = std::fs::remove_dir_all(&dir);
795 }
796
797 #[test]
803 fn scrape_sweep_writes_updates_and_clears_via_stub_mux() {
804 let _guard = crate::claims::test_env_lock()
805 .lock()
806 .unwrap_or_else(|p| p.into_inner());
807 let dir = std::env::temp_dir().join(format!("fno-scrape-sweep-{}", std::process::id()));
808 let _ = std::fs::remove_dir_all(&dir);
809 std::fs::create_dir_all(&dir).unwrap();
810 let home = AgentsHome::at(dir.join("agents"));
811 home.ensure_root().unwrap();
812
813 let mut row = entry("scrapee", "codex");
815 row.mux = Some(state::MuxRef {
816 session: "main".into(),
817 pane_id: 7,
818 });
819 state::update_registry(&home.registry_json(), |r| r.entries.push(row)).unwrap();
820
821 let ls_path = dir.join("ls.json");
824 let read_path = dir.join("read.json");
825 let stub = dir.join("fno-stub.sh");
826 std::fs::write(
827 &stub,
828 format!(
829 "#!/bin/sh\ncase \"$3\" in\nls) cat {} ;;\nread) cat {} ;;\nesac\n",
830 ls_path.display(),
831 read_path.display()
832 ),
833 )
834 .unwrap();
835 #[cfg(unix)]
836 {
837 use std::os::unix::fs::PermissionsExt;
838 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
839 }
840 std::env::set_var("FNO_BIN", &stub);
841
842 let live_pane =
843 r#"[{"pane_id":7,"squad_id":1,"tab_id":1,"cwd":"/w","child_pid":42,"title":null}]"#;
844 std::fs::write(&ls_path, live_pane).unwrap();
846 std::fs::write(&read_path, r#"{"pane_id":7,"text":"some scrollback\n› "}"#).unwrap();
847 let emitter = EventEmitter::new(home.events_jsonl(), "test");
848
849 scrape_sweep(&home, &emitter, false);
850 let reg = state::load_registry(&home.registry_json()).unwrap();
851 let v = reg.entries[0].screen_state.clone().expect("verdict stored");
852 assert_eq!(v.state, "idle");
853 assert_eq!(v.seq, 1);
854
855 scrape_sweep(&home, &emitter, false);
857 let reg = state::load_registry(&home.registry_json()).unwrap();
858 assert_eq!(reg.entries[0].screen_state.as_ref().unwrap().seq, 1);
859
860 std::fs::write(
862 &read_path,
863 r#"{"pane_id":7,"text":"Working (3s • esc to interrupt)"}"#,
864 )
865 .unwrap();
866 scrape_sweep(&home, &emitter, false);
867 let reg = state::load_registry(&home.registry_json()).unwrap();
868 let v = reg.entries[0].screen_state.clone().expect("verdict kept");
869 assert_eq!(v.state, "working");
870 assert_eq!(v.seq, 2);
871
872 std::fs::write(&ls_path, "[]").unwrap();
875 scrape_sweep(&home, &emitter, false);
876 let reg = state::load_registry(&home.registry_json()).unwrap();
877 assert_eq!(reg.entries[0].screen_state, None);
878
879 std::env::remove_var("FNO_BIN");
880 let _ = std::fs::remove_dir_all(&dir);
881 }
882
883 #[test]
884 fn decide_skip_state_update_holds_current_and_keeps_it_fresh() {
885 let m = one_rule_manifest("idle", "Showing detailed transcript", true);
888 let held = rep("working", "2026-07-02T00:00:00Z", 6);
889 let d = decide(
890 Some(&held),
891 verdict_from(&m, "Showing detailed transcript"),
892 None,
893 now_secs(),
894 NOW_STAMP,
895 );
896 let Decision::Write(new) = d else {
897 panic!("expected refresh Write, got {d:?}");
898 };
899 assert_eq!(new.state, "working", "held state, not the rule's");
900 assert_eq!(new.seq, 7);
901 let fresh = rep("working", NOW_STAMP, 6);
903 assert_eq!(
904 decide(
905 Some(&fresh),
906 verdict_from(&m, "Showing detailed transcript"),
907 None,
908 now_secs(),
909 NOW_STAMP
910 ),
911 Decision::Hold
912 );
913 assert_eq!(
914 decide(
915 None,
916 verdict_from(&m, "Showing detailed transcript"),
917 None,
918 now_secs(),
919 NOW_STAMP
920 ),
921 Decision::Hold
922 );
923 }
924}