1use crate::{completion_output::allow_output, delivery_completion::pr_passes};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum TerminationReason {
23 DonePRGreen,
24 DoneAdvisory,
25 DoneDelivery,
26 DoneBatched,
31 DoneAwaitingMerge,
40 DonePlanned,
48 NoWork,
49 Budget,
50 NoProgress,
51 Interrupted,
52 Aborted,
53}
54
55#[derive(Debug)]
59struct Manifest {
60 session_id: Option<String>,
61 created_at: Option<String>,
62 attended: bool, advisory: bool,
64 no_ship: bool,
65 no_external: bool,
66 batched: bool,
68 planned: bool,
71 plan_path: Option<String>,
73 legacy_status: Option<String>, budget_wall_clock_cap_minutes: Option<Result<u64, String>>,
76 budget_cost_cap_usd: Option<Result<f64, String>>,
78}
79
80impl Default for Manifest {
81 fn default() -> Self {
82 Self {
83 session_id: None,
84 created_at: None,
85 attended: true, advisory: false,
87 no_ship: false,
88 no_external: false,
89 batched: false,
90 planned: false,
91 plan_path: None,
92 legacy_status: None,
93 budget_wall_clock_cap_minutes: None, budget_cost_cap_usd: None, }
96 }
97}
98
99fn scan_manifest_field(content: &str, field: &str) -> Option<String> {
105 let prefix = format!("{field}:");
106 content.lines().find_map(|line| {
107 let line = line.trim();
108 line.strip_prefix(&prefix)
109 .map(|v| v.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
110 .filter(|v| !v.is_empty())
111 })
112}
113
114fn parse_manifest(content: &str) -> Option<Manifest> {
118 let content = content.trim_start();
119 if !content.starts_with("---") {
120 return None;
121 }
122 let after_first = &content[3..];
123 let end = after_first.find("\n---")?;
125 let body = &after_first[..end];
126
127 let mut m = Manifest {
128 attended: true, ..Default::default()
130 };
131
132 for line in body.lines() {
133 let line = line.trim();
134 if line.is_empty() || line.starts_with('#') {
135 continue;
136 }
137 if let Some((k, v)) = line.split_once(':') {
138 let k = k.trim();
139 let v = v.trim().trim_matches(|c| c == '"' || c == '\'');
142 match k {
143 "fno_id" => m.session_id = Some(v.to_string()),
146 "session_id" => {
147 if m.session_id.is_none() {
148 m.session_id = Some(v.to_string());
149 }
150 }
151 "created_at" => m.created_at = Some(v.to_string()),
152 "attended" => m.attended = v == "true",
153 "advisory" => m.advisory = v == "true",
154 "no_ship" => m.no_ship = v == "true",
155 "no_external" => m.no_external = v == "true",
156 "batched" => m.batched = v == "true",
157 "planned" => m.planned = v == "true",
158 "plan_path" => {
159 if !v.is_empty() {
160 m.plan_path = Some(v.to_string());
161 }
162 }
163 "status" => {
164 let upper = v.to_uppercase();
165 if matches!(upper.as_str(), "COMPLETE" | "BLOCKED" | "ABORTED") {
166 m.legacy_status = Some(upper);
167 }
168 }
169 "budget_wall_clock_cap_minutes" => {
170 let stripped = v
173 .split_once('#')
174 .map(|(before, _)| before.trim())
175 .unwrap_or(v);
176 m.budget_wall_clock_cap_minutes = Some(stripped.parse::<u64>().map_err(|_| {
177 eprintln!(
178 "loop-check: malformed budget cap 'budget_wall_clock_cap_minutes: {v}' - failing closed; fix the config"
179 );
180 v.to_string()
181 }));
182 }
183 "budget_cost_cap_usd" => {
184 let stripped = v
185 .split_once('#')
186 .map(|(before, _)| before.trim())
187 .unwrap_or(v);
188 m.budget_cost_cap_usd = Some(stripped.parse::<f64>().map_err(|_| {
189 eprintln!(
190 "loop-check: malformed budget cap 'budget_cost_cap_usd: {v}' - failing closed; fix the config"
191 );
192 v.to_string()
193 }));
194 }
195 _ => {}
196 }
197 }
198 }
199 Some(m)
200}
201
202#[derive(Debug, Default)]
205struct Settings {
206 attended_wall_cap_minutes: Option<Result<u64, String>>,
209 attended_cost_cap_usd: Option<Result<f64, String>>,
211 unattended_wall_cap_minutes: Option<Result<u64, String>>,
213 unattended_cost_cap_usd: Option<Result<f64, String>>,
215 flat_budget_cap: Option<Result<f64, String>>,
217 ci_declared_none: bool,
219 external_reviewers: Vec<String>,
221 github_apps: Option<Vec<String>>,
226 required_bots: Option<Vec<String>>,
229 peers: Vec<PeerEntry>,
233 peer_identity: Option<String>,
235 optional_apps: Option<Vec<String>>,
241 reviewers: Vec<String>,
249 nudge_overrides: Vec<NudgeOverride>,
254 done_probes: Option<Result<Vec<String>, String>>,
263}
264
265fn normalize_reviewer(raw: &str) -> String {
269 raw.trim().trim_start_matches('/').to_string()
270}
271
272const MALFORMED_REVIEWERS_SENTINEL: &str = "\u{0}malformed-reviewers";
279
280#[derive(Debug, Default, Clone)]
286struct PeerEntry {
287 provider: String,
288 model: Option<String>,
289 identity: Option<String>,
290}
291
292fn strip_inline_comment(raw: &str) -> &str {
297 if raw.starts_with('#') {
298 return "";
299 }
300 match raw.find(" #").or_else(|| raw.find("\t#")) {
301 Some(i) => raw[..i].trim_end(),
302 None => raw,
303 }
304}
305
306const UNPARSEABLE_SETTINGS_SENTINEL: &str = "\u{0}unparseable-settings\u{0}";
315
316fn scalar_as_singleton(rest: &str) -> Option<Vec<String>> {
323 let v = strip_inline_comment(rest.trim())
324 .trim_matches(|c| c == '"' || c == '\'')
325 .to_string();
326 if v.is_empty() || v.contains('{') || v.contains('}') {
327 None
328 } else {
329 Some(vec![v])
330 }
331}
332
333fn scalar_string(v: &toml::Value) -> Option<String> {
338 match v {
339 toml::Value::String(s) => Some(s.clone()),
340 toml::Value::Boolean(b) => Some(b.to_string()),
341 toml::Value::Integer(n) => Some(n.to_string()),
342 toml::Value::Float(f) => Some(f.to_string()),
343 _ => None,
344 }
345}
346
347fn value_as_login_list(v: &toml::Value) -> Option<Vec<String>> {
354 match v {
355 toml::Value::Array(items) => Some(items.iter().filter_map(scalar_string).collect()),
356 toml::Value::String(_)
359 | toml::Value::Boolean(_)
360 | toml::Value::Integer(_)
361 | toml::Value::Float(_) => scalar_string(v).and_then(|s| scalar_as_singleton(&s)),
362 _ => None,
364 }
365}
366
367#[derive(Debug, Clone, Default)]
371struct NudgeOverride {
372 login: String,
373 review_handle: Option<String>,
374 wait_minutes: Option<i64>,
375 ceiling: Option<usize>,
376 enabled: bool,
379 malformed: bool,
381}
382
383fn value_as_nudge_overrides(v: &toml::Value) -> Vec<NudgeOverride> {
388 let Some(table) = v.as_table() else {
389 return Vec::new();
391 };
392 let mut out = Vec::new();
393 for (login, entry) in table {
394 let mut ov = NudgeOverride {
395 login: login.clone(),
396 enabled: true,
397 ..Default::default()
398 };
399 let Some(map) = entry.as_table() else {
400 ov.malformed = true;
402 out.push(ov);
403 continue;
404 };
405 if let Some(rh) = map.get("review_handle") {
406 match rh.as_str() {
407 Some(s) => ov.review_handle = Some(s.to_string()),
408 None => ov.malformed = true,
409 }
410 }
411 if let Some(wm) = map.get("wait_minutes") {
412 match wm.as_integer() {
413 Some(n) if (1..=MAX_NUDGE_WAIT_MINUTES).contains(&n) => ov.wait_minutes = Some(n),
417 _ => ov.malformed = true, }
419 }
420 if let Some(c) = map.get("ceiling") {
421 match c.as_integer() {
422 Some(n) if (1..=MAX_NUDGE_CEILING).contains(&n) => ov.ceiling = Some(n as usize),
423 _ => ov.malformed = true,
424 }
425 }
426 if let Some(en) = map.get("enabled") {
427 match en.as_bool() {
428 Some(b) => ov.enabled = b,
429 None => ov.malformed = true,
430 }
431 }
432 out.push(ov);
433 }
434 out
435}
436
437fn value_as_reviewers(v: &toml::Value) -> Vec<String> {
442 match v {
443 toml::Value::Array(items) => {
444 let mut out = Vec::new();
445 for it in items {
446 match scalar_string(it) {
447 Some(s) => {
448 let n = normalize_reviewer(&s);
449 if !n.is_empty() {
450 out.push(n);
451 }
452 }
453 None => return vec![MALFORMED_REVIEWERS_SENTINEL.to_string()],
458 }
459 }
460 out
461 }
462 toml::Value::String(s) => {
463 let n = normalize_reviewer(s);
464 if n.is_empty() {
465 Vec::new()
466 } else {
467 vec![n]
468 }
469 }
470 _ => vec![MALFORMED_REVIEWERS_SENTINEL.to_string()],
472 }
473}
474
475fn value_as_peers(v: &toml::Value) -> Vec<PeerEntry> {
480 let scalar_entry = |s: String| PeerEntry {
481 provider: s,
482 model: None,
483 identity: None,
484 };
485 let map_entry = |it: &toml::Value| -> Option<PeerEntry> {
487 let provider = it
488 .get("provider")
489 .and_then(scalar_string)
490 .unwrap_or_default();
491 let model = it
492 .get("model")
493 .and_then(scalar_string)
494 .filter(|s| !s.is_empty());
495 let identity = it
496 .get("identity")
497 .and_then(scalar_string)
498 .filter(|s| !s.is_empty());
499 if provider.is_empty() && identity.is_none() {
500 None
501 } else {
502 Some(PeerEntry {
503 provider,
504 model,
505 identity,
506 })
507 }
508 };
509 match v {
510 toml::Value::Array(items) => items
511 .iter()
512 .filter_map(|it| match it {
513 toml::Value::Table(_) => map_entry(it),
514 _ => scalar_string(it)
515 .filter(|s| !s.is_empty())
516 .map(scalar_entry),
517 })
518 .collect(),
519 toml::Value::String(s) if !s.is_empty() => vec![scalar_entry(s.clone())],
520 toml::Value::Table(_) => map_entry(v).into_iter().collect(),
525 _ => Vec::new(),
526 }
527}
528
529fn read_f64_cap(v: &toml::Value, ctx: &str) -> Option<Result<f64, String>> {
533 match v {
534 toml::Value::Integer(n) => Some(Ok(*n as f64)),
535 toml::Value::Float(f) => Some(Ok(*f)),
536 other => {
537 let raw = scalar_string(other).unwrap_or_default();
538 Some(raw.parse::<f64>().map_err(|_| {
539 eprintln!(
540 "loop-check: malformed budget cap '{ctx}: {raw}' - failing closed; fix the config"
541 );
542 raw
543 }))
544 }
545 }
546}
547
548fn read_u64_cap(v: &toml::Value, ctx: &str) -> Option<Result<u64, String>> {
550 match v {
551 toml::Value::Integer(n) => Some(u64::try_from(*n).map_err(|_| {
552 eprintln!(
553 "loop-check: malformed budget cap '{ctx}: {n}' - failing closed; fix the config"
554 );
555 n.to_string()
556 })),
557 other => {
558 let raw = scalar_string(other).unwrap_or_default();
559 Some(raw.parse::<u64>().map_err(|_| {
560 eprintln!(
561 "loop-check: malformed budget cap '{ctx}: {raw}' - failing closed; fix the config"
562 );
563 raw
564 }))
565 }
566 }
567}
568
569fn value_as_probe_list(v: &toml::Value) -> Result<Vec<String>, String> {
575 let items = v
576 .as_array()
577 .ok_or_else(|| format!("it is a {}, not an array of strings", v.type_str()))?;
578 items
579 .iter()
580 .map(|i| {
581 i.as_str().map(str::to_string).ok_or_else(|| {
582 format!(
583 "it holds a {} where a command string was expected",
584 i.type_str()
585 )
586 })
587 })
588 .collect()
589}
590
591fn fail_closed_settings() -> Settings {
599 let sentinel = Some(vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]);
600 Settings {
601 github_apps: sentinel.clone(),
602 required_bots: sentinel,
603 ..Default::default()
604 }
605}
606
607fn parse_settings_result(content: &str) -> Result<Settings, String> {
615 let root: toml::Value = content.parse::<toml::Value>().map_err(|e| e.to_string())?;
616 let mut s = Settings::default();
617
618 if let Some(v) = root.get("budget_cap") {
620 s.flat_budget_cap = read_f64_cap(v, "budget_cap");
621 }
622
623 if let Some(v) = root.get("done_probes") {
627 s.done_probes = Some(value_as_probe_list(v));
628 }
629
630 if let Some(budget) = root.get("budget") {
633 if let Some(att) = budget.get("attended") {
634 if let Some(v) = att.get("wall_clock_cap_minutes") {
635 s.attended_wall_cap_minutes = read_u64_cap(v, "attended.wall_clock_cap_minutes");
636 }
637 if let Some(v) = att.get("cost_cap_usd") {
638 s.attended_cost_cap_usd = read_f64_cap(v, "attended.cost_cap_usd");
639 }
640 }
641 if let Some(un) = budget.get("unattended") {
642 if let Some(v) = un.get("wall_clock_cap_minutes") {
643 s.unattended_wall_cap_minutes =
644 read_u64_cap(v, "unattended.wall_clock_cap_minutes");
645 }
646 if let Some(v) = un.get("cost_cap_usd") {
647 s.unattended_cost_cap_usd = read_f64_cap(v, "unattended.cost_cap_usd");
648 }
649 }
650 }
651
652 if let Some(ci) = root.get("ci") {
653 s.ci_declared_none = ci
654 .get("declared_none")
655 .and_then(|v| v.as_bool())
656 .unwrap_or(false);
657 }
658
659 if let Some(er) = root.get("external_reviewers") {
660 if let Some(items) = er.as_array() {
661 s.external_reviewers = items.iter().filter_map(scalar_string).collect();
662 }
663 }
664
665 if let Some(review) = root.get("review") {
666 if let Some(v) = review.get("required_bots") {
667 s.required_bots = value_as_login_list(v);
668 }
669 if let Some(v) = review.get("github_apps") {
670 s.github_apps = value_as_login_list(v);
671 }
672 if let Some(v) = review.get("optional_apps") {
673 s.optional_apps = value_as_login_list(v);
674 }
675 if let Some(v) = review.get("reviewers") {
676 s.reviewers = value_as_reviewers(v);
677 }
678 if let Some(v) = review.get("nudge") {
679 s.nudge_overrides = value_as_nudge_overrides(v);
680 }
681 if let Some(v) = review.get("peers") {
682 s.peers = value_as_peers(v);
683 }
684 if let Some(v) = review.get("peer_identity") {
685 s.peer_identity = scalar_string(v).filter(|s| !s.is_empty());
686 }
687 }
688
689 Ok(s)
690}
691
692#[cfg(test)]
697fn parse_settings(content: &str) -> Settings {
698 parse_settings_result(content).unwrap_or_else(|_| fail_closed_settings())
699}
700
701fn session_cost_from_ledger(ledger_path: &Path, session_id: &str) -> f64 {
705 let Ok(content) = std::fs::read_to_string(ledger_path) else {
706 return 0.0;
707 };
708 let Ok(arr) = serde_json::from_str::<Value>(&content) else {
709 return 0.0;
710 };
711 let Some(entries) = arr.as_array() else {
712 return 0.0;
713 };
714 let mut total = 0.0_f64;
715 for entry in entries {
716 let matches = entry.get("fno_id").and_then(|v| v.as_str()) == Some(session_id)
718 || entry.get("session_id").and_then(|v| v.as_str()) == Some(session_id);
719 if matches {
720 if let Some(c) = entry.get("cost_usd").and_then(|v| v.as_f64()) {
721 total += c;
722 }
723 }
724 }
725 total
726}
727
728#[derive(Debug, PartialEq)]
731enum Intent {
732 Promise,
733 Aborted {
734 reason: String,
735 },
736 Watching {
742 reason: String,
743 pr: Option<String>,
744 timeout: Option<String>,
745 },
746 None,
747}
748
749fn extract_assistant_text(val: &Value) -> String {
750 if let Some(s) = val.pointer("/message/content").and_then(|v| v.as_str()) {
752 return s.to_string();
753 }
754 if let Some(arr) = val.pointer("/message/content").and_then(|v| v.as_array()) {
756 let mut parts = Vec::new();
757 for block in arr {
758 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
760 if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
761 parts.push(t.to_string());
762 }
763 }
764 }
765 return parts.join(" ");
766 }
767 if let Some(s) = val.get("content").and_then(|v| v.as_str()) {
769 return s.to_string();
770 }
771 String::new()
772}
773
774fn detect_intent_from_text(text: &str) -> Intent {
779 if let Some(aborted_start) = text.find("<aborted") {
781 if let Some(gt) = text[aborted_start..].find('>') {
783 let tag_text = &text[aborted_start..aborted_start + gt + 1];
784 let reason = parse_xml_attr(tag_text, "reason").unwrap_or_default();
785 return Intent::Aborted { reason };
786 }
787 }
788 if let Some(w_start) = text.find("<watching") {
789 if let Some(gt) = text[w_start..].find('>') {
790 let tag_text = &text[w_start..w_start + gt + 1];
791 return Intent::Watching {
792 reason: parse_xml_attr(tag_text, "reason").unwrap_or_default(),
793 pr: parse_xml_attr(tag_text, "pr"),
794 timeout: parse_xml_attr(tag_text, "timeout"),
795 };
796 }
797 }
798 if text.contains("<promise>") {
799 return Intent::Promise;
800 }
801 Intent::None
802}
803
804fn parse_xml_attr(tag_text: &str, attr: &str) -> Option<String> {
805 let pattern = format!(r#"{attr}=""#);
806 let start = tag_text.find(&pattern)? + pattern.len();
807 let end = tag_text[start..].find('"')?;
808 Some(tag_text[start..start + end].to_string())
809}
810
811fn extract_last_assistant_message(hook_input: &str) -> Option<String> {
817 let val: Value = serde_json::from_str(hook_input).ok()?;
818 let s = val.get("last_assistant_message")?.as_str()?;
819 let trimmed = s.trim();
820 if trimmed.is_empty() {
821 None
822 } else {
823 Some(trimmed.to_string())
824 }
825}
826
827fn detect_intent(
834 last_assistant_message: Option<&str>,
835 transcript_path: &Path,
836) -> (Intent, &'static str) {
837 match last_assistant_message {
838 Some(text) => (detect_intent_from_text(text), "payload"),
839 None => (detect_intent_full(transcript_path), "transcript"),
840 }
841}
842
843const INTENT_LOOKBACK_ENTRIES: usize = 5;
851
852fn detect_intent_full(transcript_path: &Path) -> Intent {
853 let Ok(content) = std::fs::read_to_string(transcript_path) else {
854 return Intent::None;
855 };
856
857 let lines: Vec<&str> = content.lines().collect();
858 let mut scanned: usize = 0;
859 let mut newest_entry = true;
863 for line in lines.iter().rev() {
864 let line = line.trim();
865 if line.is_empty() {
866 continue;
867 }
868 let Ok(val) = serde_json::from_str::<Value>(line) else {
869 continue;
870 };
871 let role = val
872 .pointer("/message/role")
873 .or_else(|| val.get("role"))
874 .and_then(|v| v.as_str())
875 .unwrap_or("");
876 if role != "assistant" {
877 continue;
878 }
879 let text = extract_assistant_text(&val);
880 if text.is_empty() {
881 continue;
882 }
883 match detect_intent_from_text(&text) {
884 Intent::None => {
885 scanned += 1;
886 if scanned >= INTENT_LOOKBACK_ENTRIES {
887 return Intent::None;
888 }
889 }
890 Intent::Watching { .. } if !newest_entry => {
893 scanned += 1;
894 if scanned >= INTENT_LOOKBACK_ENTRIES {
895 return Intent::None;
896 }
897 }
898 tagged => return tagged,
899 }
900 newest_entry = false;
901 }
902 Intent::None
903}
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911enum PrState {
912 Open,
913 Merged,
914 Closed,
915 None,
917}
918
919impl PrState {
920 fn from_gh_str(s: &str) -> Self {
921 match s {
922 "OPEN" => PrState::Open,
923 "MERGED" => PrState::Merged,
924 "CLOSED" => PrState::Closed,
925 _ => PrState::None,
926 }
927 }
928
929 fn as_str(&self) -> &'static str {
930 match self {
931 PrState::Open => "OPEN",
932 PrState::Merged => "MERGED",
933 PrState::Closed => "CLOSED",
934 PrState::None => "none",
935 }
936 }
937
938 fn is_open_or_merged(&self) -> bool {
939 matches!(self, PrState::Open | PrState::Merged)
940 }
941}
942
943#[derive(Debug, Clone, PartialEq, Eq)]
946enum CiConclusion {
947 Success,
948 Failure(Option<String>),
950 Pending,
951 Skipped,
953 None,
955}
956
957impl CiConclusion {
958 fn render(&self) -> String {
959 match self {
960 CiConclusion::Success => "SUCCESS".to_string(),
961 CiConclusion::Failure(Some(name)) => format!("FAILURE:{name}"),
962 CiConclusion::Failure(None) => "FAILURE".to_string(),
963 CiConclusion::Pending => "PENDING".to_string(),
964 CiConclusion::Skipped => "skipped".to_string(),
965 CiConclusion::None => "none".to_string(),
966 }
967 }
968
969 fn is_ok(&self) -> bool {
970 matches!(self, CiConclusion::Success | CiConclusion::Skipped)
971 }
972}
973
974#[derive(Debug)]
975struct PrInfo {
976 state: PrState,
977 number: i64,
978 head_oid: String,
981 ci_conclusion: CiConclusion,
982 failing_checks: Vec<String>,
986 ci_has_pending: bool,
991 mergeable: String,
996 latest_review_ts: String,
999 reviewed: bool, missing_bots: Vec<String>,
1003 bot_nudges: Vec<BotNudge>,
1009 usage_limited: Vec<String>,
1013 unaddressed_findings: Vec<Finding>,
1016 review_skipped: bool,
1020 unattested_reviewers: Vec<UnattestedReviewer>,
1024 malformed_attestations: usize,
1029}
1030
1031const REVIEWER_INVOCATIONS: &[(&str, &str, bool)] = &[
1040 ("sigma", "/fno:review sigma", false),
1041 (
1042 "code-review",
1043 "/code-review, then bash skills/review/scripts/emit-attestation.sh code-review",
1044 false,
1045 ),
1046 ("declare", "/fno:review declare", true),
1047];
1048
1049fn reviewer_invocation(name: &str) -> Option<(&'static str, bool)> {
1053 REVIEWER_INVOCATIONS
1054 .iter()
1055 .find(|(n, _, _)| *n == name)
1056 .map(|(_, inv, self_cert)| (*inv, *self_cert))
1057}
1058
1059fn git_head_sha(git_bin: &str, cwd: &Path) -> String {
1060 let out = Command::new(git_bin)
1061 .args(["rev-parse", "HEAD"])
1062 .current_dir(cwd)
1063 .output();
1064 match out {
1065 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1066 _ => "unknown".to_string(),
1067 }
1068}
1069
1070fn is_no_pr_stderr(stderr: &[u8]) -> bool {
1077 String::from_utf8_lossy(stderr)
1078 .to_lowercase()
1079 .contains("no pull requests found")
1080}
1081
1082fn stderr_tail(bytes: &[u8]) -> String {
1084 let s = String::from_utf8_lossy(bytes);
1085 let s = s.trim();
1086 if s.len() <= 200 {
1087 s.to_string()
1088 } else {
1089 let mut start = s.len() - 200;
1092 while start < s.len() && !s.is_char_boundary(start) {
1093 start += 1;
1094 }
1095 s[start..].to_string()
1096 }
1097}
1098
1099#[derive(Debug, Clone, PartialEq)]
1101struct UnattestedReviewer {
1102 name: String,
1103 superseded_head: Option<String>,
1109 failed_at_head: bool,
1113}
1114
1115fn unattested_reviewers_scan(
1137 events_path: &Path,
1138 reviewers: &[String],
1139 head_sha: &str,
1140) -> (Vec<UnattestedReviewer>, usize) {
1141 let unsatisfied_all = || -> Vec<UnattestedReviewer> {
1142 reviewers
1143 .iter()
1144 .map(|r| UnattestedReviewer {
1145 name: r.trim_start_matches('/').to_string(),
1146 superseded_head: None,
1147 failed_at_head: false,
1148 })
1149 .collect()
1150 };
1151 if reviewers.is_empty() {
1152 return (Vec::new(), 0);
1153 }
1154 let Ok(content) = std::fs::read_to_string(events_path) else {
1155 return (unsatisfied_all(), 0);
1157 };
1158 let mut malformed = 0usize;
1159 let mut latest_pass: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
1166 let mut other_heads: std::collections::HashMap<String, Vec<(String, bool)>> =
1173 std::collections::HashMap::new();
1174 for line in content.lines() {
1175 let Ok(val) = serde_json::from_str::<Value>(line) else {
1176 if line.contains("review_attestation") {
1177 malformed += 1;
1178 }
1179 continue;
1180 };
1181 if val.get("type").and_then(|v| v.as_str()) != Some("review_attestation") {
1182 continue;
1183 }
1184 let Some(r) = val.pointer("/data/reviewer").and_then(|v| v.as_str()) else {
1185 continue;
1186 };
1187 let r = r.trim_start_matches('/').to_string();
1188 let Some(line_head) = val.pointer("/data/head_sha").and_then(|v| v.as_str()) else {
1193 continue;
1194 };
1195 let is_pass = val.pointer("/data/verdict").and_then(|v| v.as_str()) == Some("pass");
1196 if line_head != head_sha {
1197 if line_head.is_empty() {
1200 continue;
1201 }
1202 let seen = other_heads.entry(r).or_default();
1203 match seen.iter().position(|(h, _)| h == line_head) {
1204 Some(i) => seen[i].1 = is_pass, None => seen.push((line_head.to_string(), is_pass)),
1206 }
1207 continue;
1208 }
1209 latest_pass.insert(r, is_pass);
1210 }
1211 let out = reviewers
1212 .iter()
1213 .map(|entry| entry.trim_start_matches('/'))
1214 .filter(|name| latest_pass.get(*name) != Some(&true))
1215 .map(|name| UnattestedReviewer {
1216 name: name.to_string(),
1217 superseded_head: other_heads
1224 .get(name)
1225 .and_then(|heads| heads.iter().rev().find(|(_, ok)| *ok))
1226 .map(|(h, _)| h.clone()),
1227 failed_at_head: latest_pass.get(name) == Some(&false),
1228 })
1229 .collect();
1230 (out, malformed)
1231}
1232
1233#[derive(Debug, Clone)]
1236struct OpenFinding {
1237 id: String,
1238 first_line: String,
1239}
1240
1241fn open_review_findings(events_path: &Path, node: &str) -> (Vec<OpenFinding>, usize) {
1253 let Ok(content) = std::fs::read_to_string(events_path) else {
1254 return (Vec::new(), 0);
1255 };
1256 let mut findings: Vec<(String, String)> = Vec::new();
1259 let mut resolved: std::collections::HashSet<String> = std::collections::HashSet::new();
1260 let mut malformed = 0usize;
1261 for line in content.lines() {
1262 let line = line.trim();
1263 if line.is_empty() {
1264 continue;
1265 }
1266 let Ok(val) = serde_json::from_str::<Value>(line) else {
1267 if line.contains("review_finding") {
1270 malformed += 1;
1271 }
1272 continue;
1273 };
1274 match val.get("type").and_then(|v| v.as_str()) {
1275 Some("review_finding") => {
1276 if val.pointer("/data/node").and_then(|v| v.as_str()) != Some(node) {
1277 continue;
1278 }
1279 match val.pointer("/data/finding_id").and_then(|v| v.as_str()) {
1280 Some(id) => {
1281 let first = val
1282 .pointer("/data/text")
1283 .and_then(|v| v.as_str())
1284 .unwrap_or("")
1285 .lines()
1286 .next()
1287 .unwrap_or("")
1288 .to_string();
1289 if let Some(slot) = findings.iter_mut().find(|(fid, _)| fid == id) {
1290 slot.1 = first;
1291 } else {
1292 findings.push((id.to_string(), first));
1293 }
1294 }
1295 None => malformed += 1, }
1297 }
1298 Some("review_finding_resolved") => {
1299 if let Some(id) = val.pointer("/data/finding_id").and_then(|v| v.as_str()) {
1300 resolved.insert(id.to_string());
1301 }
1302 }
1303 _ => {}
1304 }
1305 }
1306 let mut open: Vec<OpenFinding> = findings
1307 .into_iter()
1308 .filter(|(id, _)| !resolved.contains(id))
1309 .map(|(id, first_line)| OpenFinding { id, first_line })
1310 .collect();
1311 open.sort_by(|a, b| a.id.cmp(&b.id)); (open, malformed)
1313}
1314
1315fn build_findings_block_reason(open: &[OpenFinding], malformed: usize) -> String {
1319 let f = &open[0];
1320 let more = if open.len() > 1 {
1321 format!(" [+{} more]", open.len() - 1)
1322 } else {
1323 String::new()
1324 };
1325 let notice = if malformed > 0 {
1326 format!(" ({malformed} malformed finding line(s) ignored)")
1327 } else {
1328 String::new()
1329 };
1330 format!(
1331 "open review finding {}: {} - address it, then `fno annotate resolve {}`{}{}",
1332 f.id, f.first_line, f.id, more, notice
1333 )
1334}
1335
1336#[allow(clippy::too_many_arguments)]
1338fn read_pr_info(
1339 gh_bin: &str,
1340 cwd: &Path,
1341 ci_declared_none: bool,
1342 no_external: bool,
1343 required_bots: &[String],
1344 optional_bots: &[String],
1345 external_reviewers: &[String],
1346 reviewers: &[String],
1347 nudge_configs: &[NudgeConfig],
1348 head_sha: &str,
1349 events_path: &Path,
1350) -> Result<PrInfo, (String, String)> {
1351 let pr_view_out = Command::new(gh_bin)
1353 .args([
1354 "pr",
1355 "view",
1356 "--json",
1357 "state,number,headRefName,headRefOid,mergeable",
1358 ])
1359 .current_dir(cwd)
1360 .output()
1361 .map_err(|e| ("pr_view".to_string(), e.to_string()))?;
1362
1363 if !pr_view_out.status.success() {
1364 if is_no_pr_stderr(&pr_view_out.stderr) {
1365 return Ok(PrInfo {
1369 state: PrState::None,
1370 number: 0,
1371 head_oid: String::new(),
1372 ci_conclusion: CiConclusion::None,
1373 failing_checks: Vec::new(),
1374 ci_has_pending: false,
1375 mergeable: "UNKNOWN".to_string(),
1376 latest_review_ts: "none".to_string(),
1377 reviewed: false,
1378 missing_bots: Vec::new(),
1379 bot_nudges: Vec::new(),
1380 usage_limited: Vec::new(),
1381 unaddressed_findings: Vec::new(),
1382 review_skipped: false,
1383 unattested_reviewers: Vec::new(),
1384 malformed_attestations: 0,
1385 });
1386 }
1387 return Err(("pr_view".to_string(), stderr_tail(&pr_view_out.stderr)));
1388 }
1389
1390 let pr_json: Value = serde_json::from_slice(&pr_view_out.stdout)
1391 .map_err(|_| ("pr_view_parse".to_string(), String::new()))?;
1392
1393 let state = PrState::from_gh_str(
1394 pr_json
1395 .get("state")
1396 .and_then(|v| v.as_str())
1397 .unwrap_or("none"),
1398 );
1399 let number = pr_json.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
1400 let head_oid = pr_json
1401 .get("headRefOid")
1402 .and_then(|v| v.as_str())
1403 .unwrap_or("")
1404 .to_string();
1405 let mergeable = pr_json
1409 .get("mergeable")
1410 .and_then(|v| v.as_str())
1411 .unwrap_or("UNKNOWN")
1412 .to_string();
1413
1414 if state == PrState::Merged {
1425 return Ok(PrInfo {
1426 state,
1427 number,
1428 head_oid,
1429 ci_conclusion: CiConclusion::Skipped,
1430 failing_checks: Vec::new(),
1431 ci_has_pending: false,
1432 mergeable,
1433 latest_review_ts: "none".to_string(),
1434 reviewed: true,
1435 missing_bots: Vec::new(),
1436 bot_nudges: Vec::new(),
1437 usage_limited: Vec::new(),
1438 unaddressed_findings: Vec::new(),
1439 review_skipped: true,
1440 unattested_reviewers: Vec::new(),
1441 malformed_attestations: 0,
1442 });
1443 }
1444
1445 let no_hosted_ci =
1450 crate::verify_evidence::hosted_ci_not_configured(ci_declared_none, cwd, head_sha);
1451 let (ci_conclusion, failing_checks, ci_has_pending) = if no_hosted_ci {
1452 (CiConclusion::Skipped, Vec::new(), false)
1453 } else {
1454 let checks_out = Command::new(gh_bin)
1455 .args(["pr", "checks", "--json", "name,state,bucket"])
1456 .current_dir(cwd)
1457 .output()
1458 .map_err(|e| ("pr_checks".to_string(), e.to_string()))?;
1459
1460 if !checks_out.status.success() {
1461 return Err(("pr_checks".to_string(), stderr_tail(&checks_out.stderr)));
1462 }
1463
1464 let checks: Value = serde_json::from_slice(&checks_out.stdout)
1465 .map_err(|_| ("pr_checks_parse".to_string(), String::new()))?;
1466
1467 let failing = failing_check_names(&checks);
1468 let has_pending = ci_has_pending_checks(&checks);
1469 (
1470 compute_ci_conclusion(&checks).map_err(|e| (e, String::new()))?,
1471 failing,
1472 has_pending,
1473 )
1474 };
1475
1476 let login_gate_active = !required_bots.is_empty() || !optional_bots.is_empty();
1495 let login_skipped = no_external || !login_gate_active;
1496 let (unattested, malformed_attestations) =
1499 unattested_reviewers_scan(events_path, reviewers, head_sha);
1500 let reviewers_ok = unattested.is_empty();
1501 let (latest_review_ts, reviewed, missing_bots, bot_nudges, usage_limited, unaddressed_findings) =
1502 if login_skipped {
1503 (
1509 "none".to_string(),
1510 reviewers_ok,
1511 Vec::new(),
1512 Vec::new(),
1513 Vec::new(),
1514 Vec::new(),
1515 )
1516 } else {
1517 let reviews_out = Command::new(gh_bin)
1519 .args(["pr", "view", "--json", "reviews,comments"])
1520 .current_dir(cwd)
1521 .output()
1522 .map_err(|e| ("pr_reviews".to_string(), e.to_string()))?;
1523
1524 if !reviews_out.status.success() {
1525 return Err(("pr_reviews".to_string(), stderr_tail(&reviews_out.stderr)));
1526 }
1527
1528 let reviews_json: Value = serde_json::from_slice(&reviews_out.stdout)
1529 .map_err(|_| ("pr_reviews_parse".to_string(), String::new()))?;
1530
1531 let info = compute_review_info(&reviews_json, required_bots);
1536 let now = Utc::now();
1542 let review_comments = reviews_json
1543 .get("comments")
1544 .and_then(|v| v.as_array())
1545 .map(|v| v.as_slice())
1546 .unwrap_or(&[]);
1547 let bot_nudges: Vec<BotNudge> = info
1548 .missing_bots
1549 .iter()
1550 .map(|bot| {
1551 classify_bot_nudge(
1552 bot,
1553 review_comments,
1554 nudge_config_for(nudge_configs, bot),
1555 now,
1556 )
1557 })
1558 .collect();
1559 debug_assert_eq!(bot_nudges.len(), info.missing_bots.len());
1565 let mut findings_bots: Vec<String> = required_bots.to_vec();
1566 for b in optional_bots {
1567 if !findings_bots.iter().any(|x| x == b) {
1568 findings_bots.push(b.clone());
1569 }
1570 }
1571
1572 let comments_out = Command::new(gh_bin)
1577 .args([
1578 "api",
1579 &format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments"),
1580 "--paginate",
1581 ])
1582 .current_dir(cwd)
1583 .output()
1584 .map_err(|e| ("pulls_comments".to_string(), e.to_string()))?;
1585
1586 if !comments_out.status.success() {
1587 return Err((
1588 "pulls_comments".to_string(),
1589 stderr_tail(&comments_out.stderr),
1590 ));
1591 }
1592
1593 let mut inline_comments: Vec<Value> = Vec::new();
1594 for page in
1595 serde_json::Deserializer::from_slice(&comments_out.stdout).into_iter::<Value>()
1596 {
1597 let page = page.map_err(|_| ("pulls_comments_parse".to_string(), String::new()))?;
1598 match page.as_array() {
1599 Some(arr) => inline_comments.extend(arr.iter().cloned()),
1600 None => return Err(("pulls_comments_parse".to_string(), String::new())),
1601 }
1602 }
1603
1604 let has_blocking_candidate = inline_comments.iter().any(|c| {
1607 c.get("in_reply_to_id").and_then(|v| v.as_i64()).is_none()
1608 && blocking_severity(c.get("body").and_then(|v| v.as_str()).unwrap_or(""))
1609 .is_some()
1610 });
1611 let commit_dates: Vec<String> = if has_blocking_candidate {
1612 let commits_out = Command::new(gh_bin)
1613 .args(["pr", "view", "--json", "commits"])
1614 .current_dir(cwd)
1615 .output()
1616 .map_err(|e| ("pr_commits".to_string(), e.to_string()))?;
1617 if !commits_out.status.success() {
1618 return Err(("pr_commits".to_string(), stderr_tail(&commits_out.stderr)));
1619 }
1620 let commits_json: Value = serde_json::from_slice(&commits_out.stdout)
1621 .map_err(|_| ("pr_commits_parse".to_string(), String::new()))?;
1622 commits_json
1623 .get("commits")
1624 .and_then(|v| v.as_array())
1625 .map(|arr| {
1626 arr.iter()
1627 .filter_map(|c| {
1628 c.get("committedDate")
1629 .and_then(|v| v.as_str())
1630 .map(|s| s.to_string())
1631 })
1632 .collect()
1633 })
1634 .unwrap_or_default()
1635 } else {
1636 Vec::new()
1637 };
1638
1639 let (inline_ts, unaddressed) = compute_unaddressed_findings(
1640 &inline_comments,
1641 &commit_dates,
1642 &findings_bots,
1643 external_reviewers,
1644 );
1645
1646 let activity_ts = max_ts(&info.latest_ts, &inline_ts);
1650 let reviewed = info.all_required_passed() && unaddressed.is_empty() && reviewers_ok;
1654 if !info.usage_limited.is_empty() {
1659 append_loop_event(
1660 events_path,
1661 "review_gate_bot_usage_limited",
1662 serde_json::json!({"pr": number, "bots": info.usage_limited.clone()}),
1663 );
1664 }
1665 (
1666 activity_ts,
1667 reviewed,
1668 info.missing_bots,
1669 bot_nudges,
1670 info.usage_limited,
1671 unaddressed,
1672 )
1673 };
1674
1675 Ok(PrInfo {
1676 state,
1677 number,
1678 head_oid,
1679 ci_conclusion,
1680 failing_checks,
1681 ci_has_pending,
1682 mergeable,
1683 latest_review_ts,
1684 reviewed,
1685 missing_bots,
1686 bot_nudges,
1687 usage_limited,
1688 unaddressed_findings,
1689 review_skipped: login_skipped && reviewers.is_empty(),
1693 unattested_reviewers: unattested,
1694 malformed_attestations,
1695 })
1696}
1697
1698fn compute_ci_conclusion(checks: &Value) -> Result<CiConclusion, String> {
1699 let arr = match checks.as_array() {
1700 Some(a) => a,
1701 None => return Err("pr_checks_parse".to_string()),
1702 };
1703
1704 if arr.is_empty() {
1705 return Ok(CiConclusion::None);
1707 }
1708
1709 let bucket_of = |check: &Value| -> String {
1716 check
1717 .get("bucket")
1718 .and_then(|v| v.as_str())
1719 .unwrap_or("")
1720 .to_lowercase()
1721 };
1722
1723 if let Some(failing) = arr
1724 .iter()
1725 .find(|c| matches!(bucket_of(c).as_str(), "fail" | "cancel"))
1726 {
1727 let name = failing
1728 .get("name")
1729 .and_then(|v| v.as_str())
1730 .unwrap_or("unknown");
1731 return Ok(CiConclusion::Failure(Some(name.to_string())));
1732 }
1733 if arr
1734 .iter()
1735 .any(|c| !matches!(bucket_of(c).as_str(), "pass" | "skipping"))
1736 {
1737 return Ok(CiConclusion::Pending);
1738 }
1739 Ok(CiConclusion::Success)
1740}
1741
1742const MAIN_RUN_LOOKBACK: usize = 10;
1759
1760fn failing_check_names(checks: &Value) -> Vec<String> {
1764 let Some(arr) = checks.as_array() else {
1765 return Vec::new();
1766 };
1767 arr.iter()
1768 .filter(|c| {
1769 let bucket = c
1770 .get("bucket")
1771 .and_then(|v| v.as_str())
1772 .unwrap_or("")
1773 .to_lowercase();
1774 matches!(bucket.as_str(), "fail" | "cancel")
1775 })
1776 .filter_map(|c| c.get("name").and_then(|v| v.as_str()).map(str::to_string))
1777 .collect()
1778}
1779
1780fn ci_has_pending_checks(checks: &Value) -> bool {
1787 let Some(arr) = checks.as_array() else {
1788 return false;
1789 };
1790 arr.iter().any(|c| {
1791 let bucket = c
1792 .get("bucket")
1793 .and_then(|v| v.as_str())
1794 .unwrap_or("")
1795 .to_lowercase();
1796 !matches!(bucket.as_str(), "pass" | "fail" | "cancel" | "skipping")
1797 })
1798}
1799
1800fn parse_failing_run_ids(run_list: &Value, head_sha: &str) -> Vec<i64> {
1806 let Some(arr) = run_list.as_array() else {
1807 return Vec::new();
1808 };
1809 arr.iter()
1810 .filter(|r| r.get("conclusion").and_then(|v| v.as_str()) == Some("failure"))
1811 .filter(|r| r.get("headSha").and_then(|v| v.as_str()) == Some(head_sha))
1812 .filter_map(|r| r.get("databaseId").and_then(|v| v.as_i64()))
1813 .collect()
1814}
1815
1816fn parse_failing_job_names(jobs_json: &Value) -> Vec<String> {
1820 let Some(jobs) = jobs_json.get("jobs").and_then(|v| v.as_array()) else {
1821 return Vec::new();
1822 };
1823 jobs.iter()
1824 .filter(|j| j.get("conclusion").and_then(|v| v.as_str()) == Some("failure"))
1825 .filter_map(|j| j.get("name").and_then(|v| v.as_str()).map(str::to_string))
1826 .collect()
1827}
1828
1829fn is_pre_existing_main_red(pr_failing: &[String], main_failing: &[String]) -> bool {
1833 if pr_failing.is_empty() {
1834 return false;
1835 }
1836 pr_failing.iter().all(|c| main_failing.contains(c))
1837}
1838
1839fn main_head_failing_checks(gh_bin: &str, cwd: &Path, n: usize) -> Option<Vec<String>> {
1849 let list_out = Command::new(gh_bin)
1850 .args([
1851 "run",
1852 "list",
1853 "--branch",
1854 "main",
1855 "--status",
1856 "completed",
1857 "--limit",
1858 &n.to_string(),
1859 "--json",
1860 "databaseId,conclusion,headSha",
1861 ])
1862 .current_dir(cwd)
1863 .output()
1864 .ok()?;
1865 if !list_out.status.success() {
1866 return None; }
1868 let list: Value = serde_json::from_slice(&list_out.stdout).ok()?;
1869 let arr = list.as_array()?;
1870 let head_sha = arr
1874 .first()
1875 .and_then(|r| r.get("headSha"))
1876 .and_then(|v| v.as_str())
1877 .filter(|s| !s.is_empty())?;
1878 let failing_run_ids = parse_failing_run_ids(&list, head_sha);
1879
1880 let mut names: Vec<String> = Vec::new();
1881 for id in failing_run_ids {
1882 let view_out = Command::new(gh_bin)
1883 .args(["run", "view", &id.to_string(), "--json", "jobs"])
1884 .current_dir(cwd)
1885 .output()
1886 .ok()?;
1887 if !view_out.status.success() {
1888 return None; }
1890 let view: Value = serde_json::from_slice(&view_out.stdout).ok()?;
1891 for name in parse_failing_job_names(&view) {
1892 if !names.contains(&name) {
1893 names.push(name);
1894 }
1895 }
1896 }
1897 Some(names)
1898}
1899
1900fn already_emitted_awaiting_merge(events_path: &Path, session_id: &str) -> bool {
1906 let Ok(content) = std::fs::read_to_string(events_path) else {
1907 return false;
1908 };
1909 content.lines().any(|line| {
1910 let Ok(val) = serde_json::from_str::<Value>(line) else {
1911 return false;
1912 };
1913 val.get("type").and_then(|v| v.as_str()) == Some("termination")
1914 && val.pointer("/data/session_id").and_then(|v| v.as_str()) == Some(session_id)
1915 && val.pointer("/data/reason").and_then(|v| v.as_str()) == Some("DoneAwaitingMerge")
1916 })
1917}
1918
1919fn best_effort_notify(title: &str, body: &str) {
1924 if std::env::var("FNO_LOOPCHECK_NO_NOTIFY").as_deref() == Ok("1") {
1925 return;
1926 }
1927 let fno_bin = std::env::var_os("FNO_LOOPCHECK_FNO_BIN").unwrap_or_else(|| "fno".into());
1930 let _ = Command::new(fno_bin).args(["notify", title, body]).spawn();
1931}
1932
1933fn post_nudge_comment(gh_bin: &str, cwd: &Path, pr_number: i64, review_handle: &str) -> bool {
1943 if std::env::var("FNO_LOOPCHECK_NO_COMMENT").as_deref() == Ok("1") {
1944 return false;
1945 }
1946 Command::new(gh_bin)
1947 .args([
1948 "pr",
1949 "comment",
1950 &pr_number.to_string(),
1951 "--body",
1952 review_handle,
1953 ])
1954 .current_dir(cwd)
1955 .output()
1956 .map(|o| o.status.success())
1957 .unwrap_or(false)
1958}
1959
1960fn unresponsive_bot(pr: &PrInfo) -> Option<&BotNudge> {
1963 pr.bot_nudges
1964 .iter()
1965 .find(|n| n.class == NudgeClass::Unresponsive)
1966}
1967
1968fn nudge_giveup_message(n: &BotNudge) -> String {
1971 format!(
1972 "{} did not review after {} nudges over {}m; giving up (NoProgress). \
1973 Move it to config.review.optional_apps or review by hand.",
1974 n.login, n.nudges, n.span_min
1975 )
1976}
1977
1978struct BotProfile {
1992 login: &'static str,
1993 review_handle: &'static str,
1994 reply_handle: &'static str,
1995 usage_markers: &'static [&'static str],
1998 nudgeable: bool,
1999}
2000
2001const BOT_PROFILES: &[BotProfile] = &[
2006 BotProfile {
2007 login: "chatgpt-codex-connector",
2008 review_handle: "@codex review",
2009 reply_handle: "@chatgpt-codex-connector",
2010 usage_markers: &["usage limits for code reviews", "codex usage limits"],
2011 nudgeable: true,
2012 },
2013 BotProfile {
2014 login: "gemini-code-assist",
2015 review_handle: "",
2016 reply_handle: "@gemini-code-assist",
2017 usage_markers: &[],
2018 nudgeable: false,
2019 },
2020];
2021
2022fn profile_by_author(author: &str) -> Option<&'static BotProfile> {
2027 BOT_PROFILES
2028 .iter()
2029 .find(|p| login_matches_bot(author, p.login))
2030}
2031
2032fn logins_correspond(a: &str, b: &str) -> bool {
2037 login_matches_bot(a, b) || login_matches_bot(b, a)
2038}
2039
2040const DEFAULT_NUDGE_WAIT_MINUTES: i64 = 15;
2044const DEFAULT_NUDGE_CEILING: usize = 3;
2045
2046const MAX_NUDGE_WAIT_MINUTES: i64 = 7 * 24 * 60; const MAX_NUDGE_CEILING: i64 = 1000;
2052
2053#[derive(Debug, Clone)]
2058struct NudgeConfig {
2059 login: String,
2060 review_handle: String,
2061 wait_minutes: i64,
2062 ceiling: usize,
2063}
2064
2065fn resolved_nudge_configs(settings: &Settings) -> Vec<NudgeConfig> {
2071 let mut out: Vec<NudgeConfig> = BOT_PROFILES
2072 .iter()
2073 .filter(|p| p.nudgeable && !p.review_handle.is_empty())
2074 .map(|p| NudgeConfig {
2075 login: p.login.to_string(),
2076 review_handle: p.review_handle.to_string(),
2077 wait_minutes: DEFAULT_NUDGE_WAIT_MINUTES,
2078 ceiling: DEFAULT_NUDGE_CEILING,
2079 })
2080 .collect();
2081
2082 for ov in &settings.nudge_overrides {
2083 let base = out
2084 .iter()
2085 .find(|c| logins_correspond(&c.login, &ov.login))
2086 .cloned();
2087 out.retain(|c| !logins_correspond(&c.login, &ov.login));
2089 if ov.malformed || !ov.enabled {
2090 continue; }
2092 let handle = ov
2093 .review_handle
2094 .clone()
2095 .or_else(|| base.as_ref().map(|b| b.review_handle.clone()))
2096 .filter(|h| !h.is_empty());
2097 let Some(review_handle) = handle else {
2098 continue; };
2100 out.push(NudgeConfig {
2101 login: ov.login.clone(),
2102 review_handle,
2103 wait_minutes: ov
2104 .wait_minutes
2105 .or_else(|| base.as_ref().map(|b| b.wait_minutes))
2106 .unwrap_or(DEFAULT_NUDGE_WAIT_MINUTES),
2107 ceiling: ov
2108 .ceiling
2109 .or_else(|| base.as_ref().map(|b| b.ceiling))
2110 .unwrap_or(DEFAULT_NUDGE_CEILING),
2111 });
2112 }
2113 out
2114}
2115
2116fn nudge_config_for<'a>(configs: &'a [NudgeConfig], bot: &str) -> Option<&'a NudgeConfig> {
2118 configs.iter().find(|c| logins_correspond(&c.login, bot))
2119}
2120
2121#[derive(Debug, Clone, PartialEq)]
2126enum NudgeClass {
2127 NeedsNudge,
2130 Awaiting,
2133 Unresponsive,
2136 NotNudgeable,
2140}
2141
2142#[derive(Debug, Clone)]
2144struct BotNudge {
2145 login: String,
2146 class: NudgeClass,
2147 review_handle: String,
2149 ceiling: usize,
2150 nudges: usize,
2152 newest_age_min: i64,
2154 span_min: i64,
2157}
2158
2159impl BotNudge {
2160 fn not_nudgeable(login: &str) -> Self {
2161 BotNudge {
2162 login: login.to_string(),
2163 class: NudgeClass::NotNudgeable,
2164 review_handle: String::new(),
2165 ceiling: 0,
2166 nudges: 0,
2167 newest_age_min: 0,
2168 span_min: 0,
2169 }
2170 }
2171}
2172
2173fn nudge_class_idlable(class: &NudgeClass) -> bool {
2177 matches!(class, NudgeClass::Awaiting | NudgeClass::NotNudgeable)
2178}
2179
2180fn classify_bot_nudge(
2186 login: &str,
2187 comments: &[Value],
2188 cfg: Option<&NudgeConfig>,
2189 now: DateTime<Utc>,
2190) -> BotNudge {
2191 let Some(cfg) = cfg else {
2192 return BotNudge::not_nudgeable(login);
2193 };
2194 if cfg.review_handle.is_empty() {
2195 return BotNudge::not_nudgeable(login);
2196 }
2197 let mut total = 0usize;
2198 let mut times: Vec<DateTime<Utc>> = Vec::new();
2199 for c in comments {
2200 let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
2201 if !body.contains(&cfg.review_handle) {
2202 continue;
2203 }
2204 total += 1;
2205 if let Some(dt) = c
2208 .get("createdAt")
2209 .and_then(|v| v.as_str())
2210 .and_then(|s| s.parse::<DateTime<Utc>>().ok())
2211 {
2212 times.push(dt);
2213 }
2214 }
2215 if total == 0 {
2216 return BotNudge {
2217 login: login.to_string(),
2218 class: NudgeClass::NeedsNudge,
2219 review_handle: cfg.review_handle.clone(),
2220 ceiling: cfg.ceiling,
2221 nudges: 0,
2222 newest_age_min: 0,
2223 span_min: 0,
2224 };
2225 }
2226 let (Some(newest), Some(oldest)) = (times.iter().max().copied(), times.iter().min().copied())
2227 else {
2228 return BotNudge {
2230 login: login.to_string(),
2231 class: NudgeClass::NeedsNudge,
2232 review_handle: cfg.review_handle.clone(),
2233 ceiling: cfg.ceiling,
2234 nudges: total,
2235 newest_age_min: 0,
2236 span_min: 0,
2237 };
2238 };
2239 let newest_age_min = (now - newest).num_minutes().max(0);
2240 let span_min = (now - oldest).num_minutes().max(0);
2241 let class = if (now - newest) < chrono::Duration::minutes(cfg.wait_minutes) {
2242 NudgeClass::Awaiting
2243 } else if total >= cfg.ceiling {
2244 NudgeClass::Unresponsive
2245 } else {
2246 NudgeClass::NeedsNudge };
2248 BotNudge {
2249 login: login.to_string(),
2250 class,
2251 review_handle: cfg.review_handle.clone(),
2252 ceiling: cfg.ceiling,
2253 nudges: total,
2254 newest_age_min,
2255 span_min,
2256 }
2257}
2258
2259const DEFAULT_REQUIRED_BOTS: &[&str] = &[];
2266
2267const LOCAL_PEER_REVIEWER: &str = "peer";
2270
2271const SAME_MODEL_LOCAL_PEER_SENTINEL: &str = "\u{0}fno-peer-same-model-local\u{0}";
2275
2276const SAME_MODEL_PEER_SENTINEL: &str = "\u{0}fno-peer-same-model\u{0}";
2281
2282fn harness_family(name: &str) -> Option<&'static str> {
2290 match name.trim().to_ascii_lowercase().as_str() {
2291 "claude" | "anthropic" => Some("anthropic"),
2292 "codex" | "openai" => Some("openai"),
2293 "gemini" | "google" => Some("google"),
2294 _ => None,
2295 }
2296}
2297
2298fn route_provider(model: &str) -> Option<&str> {
2303 let mut parts = model.split(',').map(str::trim);
2304 match (parts.next(), parts.next(), parts.next()) {
2305 (Some(prov), Some(rest), None) if !prov.is_empty() && !rest.is_empty() => Some(prov),
2306 _ => None,
2307 }
2308}
2309
2310fn peer_family(peer: &PeerEntry) -> Option<&'static str> {
2318 let effective = peer
2319 .model
2320 .as_deref()
2321 .filter(|_| peer.provider.trim().eq_ignore_ascii_case("claude"))
2322 .and_then(route_provider)
2323 .unwrap_or(peer.provider.as_str());
2324 harness_family(effective)
2325}
2326
2327#[cfg(test)]
2332fn resolved_required_bots(settings: &Settings) -> Vec<String> {
2333 resolved_required_bots_for_author(settings, None)
2334}
2335
2336fn resolved_required_bots_for_author(
2348 settings: &Settings,
2349 author_harness: Option<&str>,
2350) -> Vec<String> {
2351 if settings.github_apps.is_some() && settings.required_bots.is_some() {
2353 eprintln!(
2354 "loop-check: both config.review.github_apps and required_bots set - using github_apps"
2355 );
2356 }
2357 let mut logins: Vec<String> = match settings
2358 .github_apps
2359 .as_ref()
2360 .or(settings.required_bots.as_ref())
2361 {
2362 Some(list) => list.clone(),
2363 None => DEFAULT_REQUIRED_BOTS
2364 .iter()
2365 .map(|s| s.to_string())
2366 .collect(),
2367 };
2368
2369 for peer in &settings.peers {
2373 let id = peer
2374 .identity
2375 .clone()
2376 .or_else(|| settings.peer_identity.clone());
2377 match id {
2378 Some(id) if !logins.iter().any(|l| l == &id) => logins.push(id),
2379 Some(_) => {} None => {} }
2382 }
2383
2384 if let Some(author) = author_harness.filter(|_| !settings.peers.is_empty()) {
2390 if let Some(author_fam) = harness_family(author) {
2391 apply_same_model_guard(&mut logins, settings, author, author_fam);
2392 }
2393 }
2394 logins
2395}
2396
2397fn resolved_local_peer_reviewers_for_author(
2405 settings: &Settings,
2406 author_harness: Option<&str>,
2407) -> Vec<String> {
2408 if settings.peer_identity.is_some() {
2409 return Vec::new();
2410 }
2411 let local: Vec<&PeerEntry> = settings
2412 .peers
2413 .iter()
2414 .filter(|peer| peer.identity.is_none())
2415 .collect();
2416 if local.is_empty() {
2417 return Vec::new();
2418 }
2419 let Some(author_fam) = author_harness.and_then(harness_family) else {
2420 return vec![LOCAL_PEER_REVIEWER.to_string()];
2421 };
2422 if local
2423 .iter()
2424 .any(|peer| peer_family(peer) != Some(author_fam))
2425 {
2426 vec![LOCAL_PEER_REVIEWER.to_string()]
2427 } else {
2428 eprintln!(
2429 "loop-check: every identity-free peer is the author's own model - configure a cross-model peer or routed model"
2430 );
2431 vec![SAME_MODEL_LOCAL_PEER_SENTINEL.to_string()]
2432 }
2433}
2434
2435fn apply_same_model_guard(
2445 logins: &mut Vec<String>,
2446 settings: &Settings,
2447 author_harness: &str,
2448 author_fam: &str,
2449) {
2450 let base_set = settings
2451 .github_apps
2452 .as_ref()
2453 .or(settings.required_bots.as_ref());
2454
2455 let mut seen: Vec<(String, bool, String)> = Vec::new();
2458 for peer in &settings.peers {
2459 let Some(login) = peer
2460 .identity
2461 .as_deref()
2462 .or(settings.peer_identity.as_deref())
2463 else {
2464 continue;
2465 };
2466 let cross = peer_family(peer) != Some(author_fam);
2467 match seen.iter_mut().find(|(l, _, _)| l.as_str() == login) {
2468 Some(entry) => entry.1 = entry.1 || cross,
2469 None => seen.push((login.to_string(), cross, peer.provider.clone())),
2470 }
2471 }
2472
2473 for (login, any_cross, provider) in seen {
2474 if any_cross {
2475 continue;
2476 }
2477 if base_set.is_some_and(|set| set.contains(&login)) {
2478 if !logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL) {
2482 logins.push(SAME_MODEL_PEER_SENTINEL.to_string());
2483 }
2484 } else if let Some(slot) = logins.iter_mut().find(|l| **l == login) {
2485 *slot = SAME_MODEL_PEER_SENTINEL.to_string();
2487 }
2488 eprintln!(
2489 "loop-check: peer '{provider}' is the author's own model ({author_harness}-authored run) - the cross-model gate cannot be satisfied by it; configure a cross-model peer or a model route"
2490 );
2491 }
2492}
2493
2494fn resolved_optional_bots(settings: &Settings) -> Vec<String> {
2498 settings.optional_apps.clone().unwrap_or_default()
2499}
2500
2501pub(crate) fn login_matches_bot(login: &str, bot: &str) -> bool {
2505 !bot.is_empty() && login.to_lowercase().contains(&bot.to_lowercase())
2506}
2507
2508fn is_bot_reviewer(login: &str, external_reviewers: &[String]) -> bool {
2509 if !external_reviewers.is_empty() {
2510 let login_lower = login.to_lowercase();
2511 if external_reviewers
2513 .iter()
2514 .any(|r| login_lower.contains(&r.to_lowercase()))
2515 {
2516 return true;
2517 }
2518 }
2521 login.ends_with("[bot]") || BOT_PROFILES.iter().any(|p| login.contains(p.login))
2523}
2524
2525pub(crate) fn body_is_usage_limit(body: &str) -> bool {
2533 BOT_PROFILES
2534 .iter()
2535 .flat_map(|p| p.usage_markers.iter())
2536 .any(|m| body.contains(m))
2537}
2538
2539#[derive(Debug)]
2541struct ReviewInfo {
2542 latest_ts: String,
2544 missing_bots: Vec<String>,
2549 usage_limited: Vec<String>,
2556}
2557
2558impl ReviewInfo {
2559 fn all_required_passed(&self) -> bool {
2561 self.missing_bots.is_empty()
2562 }
2563}
2564
2565fn compute_review_info(reviews_json: &Value, required_bots: &[String]) -> ReviewInfo {
2566 let reviews = reviews_json
2567 .get("reviews")
2568 .and_then(|v| v.as_array())
2569 .map(|v| v.as_slice())
2570 .unwrap_or(&[]);
2571 let comments = reviews_json
2572 .get("comments")
2573 .and_then(|v| v.as_array())
2574 .map(|v| v.as_slice())
2575 .unwrap_or(&[]);
2576
2577 let mut latest_ts = String::new(); let mut passed: Vec<bool> = vec![false; required_bots.len()];
2579
2580 for r in reviews {
2581 let login = r
2582 .pointer("/author/login")
2583 .and_then(|v| v.as_str())
2584 .unwrap_or("");
2585 let submitted_at = r.get("submittedAt").and_then(|v| v.as_str()).unwrap_or("");
2586 let state = r.get("state").and_then(|v| v.as_str()).unwrap_or("");
2587
2588 if !submitted_at.is_empty() && submitted_at > latest_ts.as_str() {
2589 latest_ts = submitted_at.to_string();
2590 }
2591
2592 if !state.is_empty() {
2593 for (i, bot) in required_bots.iter().enumerate() {
2594 if login_matches_bot(login, bot) {
2595 passed[i] = true;
2596 }
2597 }
2598 }
2599 }
2600
2601 for c in comments {
2602 let created_at = c.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
2603 if !created_at.is_empty() && created_at > latest_ts.as_str() {
2604 latest_ts = created_at.to_string();
2605 }
2606 }
2607
2608 let final_ts = if latest_ts.is_empty() {
2609 "none".to_string()
2610 } else {
2611 latest_ts
2612 };
2613
2614 let mut missing_bots: Vec<String> = required_bots
2615 .iter()
2616 .zip(passed.iter())
2617 .filter(|(_, ok)| !**ok)
2618 .map(|(bot, _)| bot.clone())
2619 .collect();
2620
2621 let mut usage_limited: Vec<String> = Vec::new();
2632 missing_bots.retain(|bot| {
2633 let rate_limited = comments.iter().any(|c| {
2634 let login = c
2635 .pointer("/author/login")
2636 .and_then(|v| v.as_str())
2637 .unwrap_or("");
2638 if !login_matches_bot(login, bot) {
2639 return false;
2640 }
2641 let body = c
2642 .get("body")
2643 .and_then(|v| v.as_str())
2644 .unwrap_or("")
2645 .to_lowercase();
2646 body_is_usage_limit(&body)
2647 });
2648 if rate_limited {
2649 usage_limited.push(bot.clone());
2650 false
2651 } else {
2652 true
2653 }
2654 });
2655
2656 ReviewInfo {
2657 latest_ts: final_ts,
2658 missing_bots,
2659 usage_limited,
2660 }
2661}
2662
2663#[derive(Debug, Clone)]
2668struct Finding {
2669 id: i64,
2670 author: String,
2672 path: String,
2673 line: i64,
2674 created_at: String,
2675 severity: &'static str,
2677}
2678
2679fn blocking_severity(body: &str) -> Option<&'static str> {
2688 if body.contains("![P1 Badge]") || body.contains("badge/P1-") {
2689 return Some("P1");
2690 }
2691 if body.contains("![critical]") || body.contains("critical-priority.svg") {
2692 return Some("critical");
2693 }
2694 if body.contains("![high]") || body.contains("high-priority.svg") {
2695 return Some("high");
2696 }
2697 None
2698}
2699
2700fn max_ts(a: &str, b: &str) -> String {
2707 if let (Ok(da), Ok(db)) = (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
2708 return if da >= db {
2709 a.to_string()
2710 } else {
2711 b.to_string()
2712 };
2713 }
2714 let a_real = !a.is_empty() && a != "none";
2715 let b_real = !b.is_empty() && b != "none";
2716 match (a_real, b_real) {
2717 (true, true) => {
2718 if a >= b {
2719 a.to_string()
2720 } else {
2721 b.to_string()
2722 }
2723 }
2724 (true, false) => a.to_string(),
2725 (false, true) => b.to_string(),
2726 (false, false) => "none".to_string(),
2727 }
2728}
2729
2730const WONTFIX_MARKER: &str = "wontfix:";
2733
2734fn ts_after(a: &str, b: &str) -> bool {
2741 match (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
2742 (Ok(da), Ok(db)) => da > db,
2743 _ => false,
2744 }
2745}
2746
2747fn compute_unaddressed_findings(
2756 comments: &[Value],
2757 commit_dates: &[String],
2758 required_bots: &[String],
2759 external_reviewers: &[String],
2760) -> (String, Vec<Finding>) {
2761 let mut latest_ts = String::new();
2762 let mut candidates: Vec<Finding> = Vec::new();
2763 let mut replies: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
2765
2766 for c in comments {
2767 let created_at = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
2768 if !created_at.is_empty() && created_at > latest_ts.as_str() {
2769 latest_ts = created_at.to_string();
2770 }
2771
2772 let login = c
2773 .pointer("/user/login")
2774 .and_then(|v| v.as_str())
2775 .unwrap_or("");
2776 let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
2777 let in_reply_to = c.get("in_reply_to_id").and_then(|v| v.as_i64());
2778
2779 match in_reply_to {
2780 Some(parent_id) => {
2781 if !is_bot_reviewer(login, external_reviewers) {
2783 replies.entry(parent_id).or_default().push(body.to_string());
2784 }
2785 }
2786 None => {
2787 let by_required_bot = required_bots
2790 .iter()
2791 .any(|bot| login_matches_bot(login, bot));
2792 if by_required_bot {
2793 if let Some(severity) = blocking_severity(body) {
2794 let Some(id) = c.get("id").and_then(|v| v.as_i64()) else {
2801 eprintln!(
2802 "loop-check: skipping blocking finding with missing id (author={login})"
2803 );
2804 continue;
2805 };
2806 candidates.push(Finding {
2807 id,
2808 author: login.to_string(),
2809 path: c
2810 .get("path")
2811 .and_then(|v| v.as_str())
2812 .unwrap_or("unknown")
2813 .to_string(),
2814 line: c
2815 .get("line")
2816 .and_then(|v| v.as_i64())
2817 .or_else(|| c.get("original_line").and_then(|v| v.as_i64()))
2818 .unwrap_or(0),
2819 created_at: created_at.to_string(),
2820 severity,
2821 });
2822 }
2823 }
2824 }
2825 }
2826 }
2827
2828 let unaddressed: Vec<Finding> = candidates
2829 .into_iter()
2830 .filter(|f| {
2831 let non_bot_replies = replies.get(&f.id);
2832 let has_reply = non_bot_replies.map(|r| !r.is_empty()).unwrap_or(false);
2833 if !has_reply {
2834 return true; }
2836 let commit_after = commit_dates.iter().any(|d| ts_after(d, &f.created_at));
2837 let wontfix = non_bot_replies
2838 .map(|rs| rs.iter().any(|b| b.to_lowercase().contains(WONTFIX_MARKER)))
2839 .unwrap_or(false);
2840 !(commit_after || wontfix)
2841 })
2842 .collect();
2843
2844 let final_ts = if latest_ts.is_empty() {
2845 "none".to_string()
2846 } else {
2847 latest_ts
2848 };
2849 (final_ts, unaddressed)
2850}
2851
2852fn make_fingerprint(
2855 head_sha: &str,
2856 pr_state: &str,
2857 ci_conclusion: &str,
2858 latest_ts: &str,
2859) -> String {
2860 format!("{head_sha}|{pr_state}|{ci_conclusion}|{latest_ts}")
2861}
2862
2863const MIN_FIRE_GAP_SECS: i64 = 300;
2871
2872fn min_fire_gap_secs() -> i64 {
2875 std::env::var("FNO_LOOPCHECK_MIN_FIRE_GAP_SECS")
2876 .ok()
2877 .and_then(|s| s.trim().parse::<i64>().ok())
2878 .unwrap_or(MIN_FIRE_GAP_SECS)
2879}
2880
2881fn read_prior_fires(
2899 events_path: &Path,
2900 session_id: &str,
2901 current_fp: &str,
2902 now: DateTime<Utc>,
2903 min_gap_secs: i64,
2904) -> (u64, u64, Option<String>, i64) {
2905 let Ok(content) = std::fs::read_to_string(events_path) else {
2906 return (0, 0, None, 0);
2907 };
2908
2909 let mut total: u64 = 0;
2910
2911 for line in content.lines() {
2912 let Ok(val) = serde_json::from_str::<Value>(line) else {
2913 continue;
2914 };
2915 if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
2916 continue;
2917 }
2918 if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
2919 continue;
2920 }
2921 total += 1;
2922 }
2923
2924 let mut consecutive: u64 = 0;
2929 let mut last_fp: Option<String> = None;
2930 let mut next_ts = now;
2931 let mut oldest_counted_ts: Option<DateTime<Utc>> = None;
2932 for line in content.lines().rev() {
2933 let Ok(val) = serde_json::from_str::<Value>(line) else {
2934 continue;
2935 };
2936 if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
2937 continue;
2938 }
2939 if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
2940 continue;
2941 }
2942 if val
2947 .pointer("/data/fp_read_failed")
2948 .and_then(|v| v.as_bool())
2949 == Some(true)
2950 {
2951 continue;
2952 }
2953 let fp = val
2954 .pointer("/data/fingerprint")
2955 .and_then(|v| v.as_str())
2956 .unwrap_or("");
2957 if last_fp.is_none() && !fp.is_empty() {
2959 last_fp = Some(fp.to_string());
2960 }
2961 if fp != current_fp {
2964 break;
2965 }
2966 let Some(ts) = val
2970 .get("ts")
2971 .and_then(|v| v.as_str())
2972 .and_then(|s| s.parse::<DateTime<Utc>>().ok())
2973 else {
2974 continue;
2975 };
2976 let gap = (next_ts - ts).num_seconds();
2977 if gap < 0 || gap >= min_gap_secs {
2980 consecutive += 1;
2981 next_ts = ts;
2982 oldest_counted_ts = Some(ts);
2983 }
2984 }
2986
2987 let streak_window_secs = oldest_counted_ts
2988 .map(|t| (now - t).num_seconds().max(0))
2989 .unwrap_or(0);
2990
2991 (total, consecutive, last_fp, streak_window_secs)
2992}
2993
2994#[derive(Debug, Serialize)]
3003struct LoopEventEnvelope<'a> {
3004 ts: String,
3005 #[serde(rename = "type")]
3006 event_type: &'a str,
3007 source: &'static str,
3008 data: serde_json::Value,
3009}
3010
3011pub(crate) fn now_rfc3339_utc() -> String {
3014 let now = chrono::Utc::now();
3016 now.format("%Y-%m-%dT%H:%M:%SZ").to_string()
3017}
3018
3019fn append_loop_event(path: &Path, event_type: &str, data: serde_json::Value) {
3022 let env = LoopEventEnvelope {
3023 ts: now_rfc3339_utc(),
3024 event_type,
3025 source: "hook",
3026 data,
3027 };
3028 let Ok(mut line) = serde_json::to_string(&env) else {
3029 eprintln!("loop-check: failed to serialize event {event_type}");
3030 return;
3031 };
3032 line.push('\n');
3033
3034 if let Some(parent) = path.parent() {
3036 let _ = std::fs::create_dir_all(parent);
3037 }
3038
3039 match std::fs::OpenOptions::new()
3040 .create(true)
3041 .append(true)
3042 .open(path)
3043 {
3044 Ok(mut f) => {
3045 if let Err(e) = f.write_all(line.as_bytes()) {
3046 eprintln!(
3047 "loop-check: failed to write event {event_type} to {}: {e}",
3048 path.display()
3049 );
3050 }
3051 }
3052 Err(e) => {
3053 eprintln!(
3054 "loop-check: failed to open events file {}: {e}",
3055 path.display()
3056 );
3057 }
3058 }
3059}
3060
3061pub(crate) fn emit_to_both(
3068 project_events: &Path,
3069 global_events: &Path,
3070 event_type: &str,
3071 data: serde_json::Value,
3072) {
3073 append_loop_event(project_events, event_type, data.clone());
3074 if project_events != global_events {
3075 append_loop_event(global_events, event_type, data);
3076 }
3077}
3078
3079fn check_cancel_sentinel(cwd: &Path, created_at: &Option<String>) -> bool {
3082 let sentinel = cwd.join(".fno/.target-cancelled");
3083 let tombstone = cwd.join(".fno/.target-cancelled-final");
3084
3085 for path in &[&tombstone, &sentinel] {
3086 if !path.exists() {
3087 continue;
3088 }
3089 if let Some(ca) = created_at {
3091 if let Ok(parsed_ca) = ca.parse::<DateTime<Utc>>() {
3092 if let Ok(meta) = std::fs::metadata(path) {
3093 if let Ok(modified) = meta.modified() {
3094 let sentinel_time: DateTime<Utc> = modified.into();
3095 if sentinel_time >= parsed_ca {
3096 return true;
3097 }
3098 continue;
3100 }
3101 }
3102 }
3103 return true;
3105 }
3106 return true;
3107 }
3108 false
3109}
3110
3111#[derive(Debug, PartialEq)]
3114enum BudgetTrip {
3115 WallClock,
3116 Cost,
3117}
3118
3119enum ResolvedCap<T> {
3124 Absent,
3125 Valid(T),
3126 Malformed(String),
3127}
3128
3129fn resolve_cap<T: Copy>(cap: &Option<Result<T, String>>) -> ResolvedCap<T> {
3130 match cap {
3131 None => ResolvedCap::Absent,
3132 Some(Ok(v)) => ResolvedCap::Valid(*v),
3133 Some(Err(raw)) => ResolvedCap::Malformed(raw.clone()),
3134 }
3135}
3136
3137fn check_budget(
3138 manifest: &Manifest,
3139 settings: &Settings,
3140 now: &DateTime<Utc>,
3141 ledger_path: &Path,
3142) -> Option<BudgetTrip> {
3143 let attended = manifest.attended;
3144
3145 let wall_cap = match resolve_cap(&manifest.budget_wall_clock_cap_minutes) {
3147 ResolvedCap::Absent => {
3148 if attended {
3149 resolve_cap(&settings.attended_wall_cap_minutes)
3150 } else {
3151 resolve_cap(&settings.unattended_wall_cap_minutes)
3152 }
3153 }
3154 other => other,
3155 };
3156
3157 match wall_cap {
3158 ResolvedCap::Malformed(raw) => {
3159 eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
3160 return Some(BudgetTrip::WallClock);
3161 }
3162 ResolvedCap::Valid(cap) => {
3163 if let Some(ca_str) = &manifest.created_at {
3164 if let Ok(created) = ca_str.parse::<DateTime<Utc>>() {
3165 let duration = now.signed_duration_since(created);
3167 let elapsed_min = if duration.num_minutes() < 0 {
3168 0u64
3169 } else {
3170 duration.num_minutes() as u64
3171 };
3172 if elapsed_min >= cap {
3173 return Some(BudgetTrip::WallClock);
3174 }
3175 }
3176 }
3177 }
3178 ResolvedCap::Absent => {}
3179 }
3180
3181 let cost_cap = match resolve_cap(&manifest.budget_cost_cap_usd) {
3183 ResolvedCap::Absent => {
3184 let nested = if attended {
3185 resolve_cap(&settings.attended_cost_cap_usd)
3186 } else {
3187 resolve_cap(&settings.unattended_cost_cap_usd)
3188 };
3189 match nested {
3190 ResolvedCap::Absent => resolve_cap(&settings.flat_budget_cap),
3191 other => other,
3192 }
3193 }
3194 other => other,
3195 };
3196
3197 match cost_cap {
3198 ResolvedCap::Malformed(raw) => {
3199 eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
3200 Some(BudgetTrip::Cost)
3201 }
3202 ResolvedCap::Valid(cap) => {
3203 if let Some(session_id) = &manifest.session_id {
3204 let cost = session_cost_from_ledger(ledger_path, session_id);
3205 if cost >= cap {
3206 return Some(BudgetTrip::Cost);
3207 }
3208 }
3209 None
3210 }
3211 ResolvedCap::Absent => None,
3212 }
3213}
3214
3215#[derive(Debug)]
3221struct LoopCheckArgs {
3222 state_path: PathBuf,
3223 transcript_path: PathBuf,
3224 cwd: PathBuf,
3225 global_settings_path: Option<PathBuf>,
3228 events_path: Option<PathBuf>,
3229 global_events_path: Option<PathBuf>,
3230 settings_path: Option<PathBuf>,
3231 ledger_path: Option<PathBuf>,
3232 now_override: Option<String>,
3233 gh_bin: String,
3234 git_bin: String,
3235 hook_input_stdin: bool,
3240}
3241
3242fn parse_args(args: &[String]) -> Result<LoopCheckArgs, String> {
3243 let mut state_path: Option<PathBuf> = None;
3244 let mut transcript_path: Option<PathBuf> = None;
3245 let mut cwd: Option<PathBuf> = None;
3246 let mut global_settings_path: Option<PathBuf> = None;
3247 let mut events_path: Option<PathBuf> = None;
3248 let mut global_events_path: Option<PathBuf> = None;
3249 let mut settings_path: Option<PathBuf> = None;
3250 let mut ledger_path: Option<PathBuf> = None;
3251 let mut now_override: Option<String> = None;
3252 let mut gh_bin = std::env::var("FNO_LOOPCHECK_GH_BIN").unwrap_or_else(|_| "gh".to_string());
3253 let mut git_bin = std::env::var("FNO_LOOPCHECK_GIT_BIN").unwrap_or_else(|_| "git".to_string());
3254 let mut hook_input_stdin = false;
3255
3256 let args = if args.first().map(|s| s.as_str()) == Some("loop-check") {
3258 &args[1..]
3259 } else {
3260 args
3261 };
3262
3263 let mut i = 0;
3264 while i < args.len() {
3265 let arg = &args[i];
3266 if let Some(val) = try_flag_value(arg, "--state", args, &mut i) {
3269 state_path = Some(PathBuf::from(val));
3270 } else if let Some(val) = try_flag_value(arg, "--transcript", args, &mut i) {
3271 transcript_path = Some(PathBuf::from(val));
3272 } else if let Some(val) = try_flag_value(arg, "--cwd", args, &mut i) {
3273 cwd = Some(PathBuf::from(val));
3274 } else if let Some(val) = try_flag_value(arg, "--events", args, &mut i) {
3275 events_path = Some(PathBuf::from(val));
3276 } else if let Some(val) = try_flag_value(arg, "--global-events", args, &mut i) {
3277 global_events_path = Some(PathBuf::from(val));
3278 } else if let Some(val) = try_flag_value(arg, "--settings", args, &mut i) {
3279 settings_path = Some(PathBuf::from(val));
3280 } else if let Some(val) = try_flag_value(arg, "--global-settings", args, &mut i) {
3281 global_settings_path = Some(PathBuf::from(val));
3282 } else if let Some(val) = try_flag_value(arg, "--ledger", args, &mut i) {
3283 ledger_path = Some(PathBuf::from(val));
3284 } else if let Some(val) = try_flag_value(arg, "--now", args, &mut i) {
3285 now_override = Some(val);
3286 } else if let Some(val) = try_flag_value(arg, "--gh-bin", args, &mut i) {
3287 gh_bin = val;
3288 } else if let Some(val) = try_flag_value(arg, "--git-bin", args, &mut i) {
3289 git_bin = val;
3290 } else if arg == "--hook-input-stdin" {
3291 hook_input_stdin = true;
3294 }
3295 i += 1;
3296 }
3297
3298 let state_path = state_path.ok_or_else(|| "--state is required".to_string())?;
3300 let transcript_path = transcript_path.ok_or_else(|| "--transcript is required".to_string())?;
3301 let cwd = cwd.ok_or_else(|| "--cwd is required".to_string())?;
3302
3303 Ok(LoopCheckArgs {
3304 state_path,
3305 transcript_path,
3306 cwd,
3307 global_settings_path,
3308 events_path,
3309 global_events_path,
3310 settings_path,
3311 ledger_path,
3312 now_override,
3313 gh_bin,
3314 git_bin,
3315 hook_input_stdin,
3316 })
3317}
3318
3319fn try_flag_value(arg: &str, flag: &str, args: &[String], i: &mut usize) -> Option<String> {
3320 if arg == flag {
3321 *i += 1;
3322 args.get(*i).cloned()
3323 } else if let Some(val) = arg.strip_prefix(&format!("{flag}=")) {
3324 Some(val.to_string())
3325 } else {
3326 None
3327 }
3328}
3329
3330pub fn decide(args: &[String]) -> (i32, String) {
3333 let parsed = match parse_args(args) {
3336 Ok(p) => p,
3337 Err(e) => {
3338 let out = serde_json::json!({ "error": e });
3339 return (2, out.to_string());
3340 }
3341 };
3342
3343 let state_path = parsed.state_path.clone();
3344 let transcript_path = parsed.transcript_path.clone();
3345 let cwd = parsed.cwd.clone();
3346
3347 let last_assistant_message: Option<String> = if parsed.hook_input_stdin {
3355 match std::io::read_to_string(std::io::stdin()) {
3356 Ok(s) => extract_last_assistant_message(&s),
3357 Err(e) => {
3358 eprintln!(
3359 "loop-check: failed to read hook input from stdin: {e}; falling back to transcript scan"
3360 );
3361 None
3362 }
3363 }
3364 } else {
3365 None
3366 };
3367
3368 let manifest_content = match std::fs::read_to_string(&state_path) {
3370 Ok(c) => c,
3371 Err(e) => {
3372 eprintln!(
3373 "loop-check: cannot read state file {}: {e}",
3374 state_path.display()
3375 );
3376 let out = allow_output(
3377 "allow",
3378 None,
3379 "corrupt/missing manifest; allowing exit",
3380 0,
3381 None,
3382 );
3383 return (0, out);
3384 }
3385 };
3386
3387 let manifest = match parse_manifest(&manifest_content) {
3388 Some(m) => m,
3389 None => {
3390 eprintln!("loop-check: corrupt manifest (no frontmatter)");
3391 let out = allow_output(
3392 "allow",
3393 None,
3394 "corrupt manifest (no frontmatter); allowing exit",
3395 0,
3396 None,
3397 );
3398 return (0, out);
3399 }
3400 };
3401
3402 if let (Some(key), Some(holder)) = (
3412 scan_manifest_field(&manifest_content, "target_claim_key"),
3413 scan_manifest_field(&manifest_content, "target_claim_holder"),
3414 ) {
3415 let ttl_ms = scan_manifest_field(&manifest_content, "target_claim_ttl")
3418 .and_then(|s| crate::claims::parse_ttl_ms(&s))
3419 .unwrap_or(7_200_000);
3420 match crate::claims::renew(&key, &holder, ttl_ms, None) {
3421 Ok(_) => {}
3422 Err(e) => eprintln!("loop-check: lease renewal for {key} failed (non-fatal): {e}"),
3423 }
3424 }
3425
3426 let project_events = parsed
3428 .events_path
3429 .clone()
3430 .unwrap_or_else(|| cwd.join(".fno/events.jsonl"));
3431
3432 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
3433 let global_events = parsed
3434 .global_events_path
3435 .clone()
3436 .unwrap_or_else(|| PathBuf::from(&home).join(".fno/events.jsonl"));
3437
3438 let ledger_path = parsed
3439 .ledger_path
3440 .clone()
3441 .unwrap_or_else(|| cwd.join(".fno/ledger.json"));
3442
3443 let parse_or_emit = |content: &str, path: &Path| -> Settings {
3453 match parse_settings_result(content) {
3454 Ok(s) => s,
3455 Err(e) => {
3456 eprintln!(
3457 "loop-check: config.toml unparseable ({}): {e} - failing the login gate closed",
3458 path.display()
3459 );
3460 emit_to_both(
3461 &project_events,
3462 &global_events,
3463 "loop_check_settings_unparseable",
3464 serde_json::json!({"path": path.display().to_string(), "error": e}),
3465 );
3466 fail_closed_settings()
3467 }
3468 }
3469 };
3470 let settings = if let Some(ref explicit) = parsed.settings_path {
3471 if let Ok(sc) = std::fs::read_to_string(explicit) {
3472 parse_or_emit(&sc, explicit)
3473 } else {
3474 Settings::default()
3475 }
3476 } else {
3477 let global_path = parsed
3478 .global_settings_path
3479 .clone()
3480 .unwrap_or_else(|| PathBuf::from(&home).join(".fno/config.toml"));
3481 let mut merged = std::fs::read_to_string(&global_path)
3482 .map(|sc| parse_or_emit(&sc, &global_path))
3483 .unwrap_or_default();
3484 let local_path = cwd.join(".fno/config.toml");
3485 if let Ok(sc) = std::fs::read_to_string(&local_path) {
3486 let local = parse_or_emit(&sc, &local_path);
3487 if local.attended_wall_cap_minutes.is_some() {
3488 merged.attended_wall_cap_minutes = local.attended_wall_cap_minutes;
3489 }
3490 if local.attended_cost_cap_usd.is_some() {
3491 merged.attended_cost_cap_usd = local.attended_cost_cap_usd;
3492 }
3493 if local.unattended_wall_cap_minutes.is_some() {
3494 merged.unattended_wall_cap_minutes = local.unattended_wall_cap_minutes;
3495 }
3496 if local.unattended_cost_cap_usd.is_some() {
3497 merged.unattended_cost_cap_usd = local.unattended_cost_cap_usd;
3498 }
3499 if local.flat_budget_cap.is_some() {
3500 merged.flat_budget_cap = local.flat_budget_cap;
3501 }
3502 if local.ci_declared_none {
3503 merged.ci_declared_none = true;
3504 }
3505 if !local.external_reviewers.is_empty() {
3506 merged.external_reviewers = local.external_reviewers;
3507 }
3508 if local.required_bots.is_some() {
3509 merged.required_bots = local.required_bots;
3512 }
3513 if local.github_apps.is_some() {
3514 merged.github_apps = local.github_apps;
3515 }
3516 if local.optional_apps.is_some() {
3517 merged.optional_apps = local.optional_apps;
3518 }
3519 if !local.reviewers.is_empty() {
3520 merged.reviewers = local.reviewers;
3521 }
3522 if !local.nudge_overrides.is_empty() {
3523 merged.nudge_overrides = local.nudge_overrides;
3529 }
3530 if !local.peers.is_empty() {
3531 merged.peers = local.peers;
3532 }
3533 if local.peer_identity.is_some() {
3534 merged.peer_identity = local.peer_identity;
3535 }
3536 if local.done_probes.is_some() {
3537 merged.done_probes = local.done_probes;
3544 }
3545 }
3546 merged
3547 };
3548
3549 let author_harness = crate::claims::resolve_harness();
3553 let required_bots = resolved_required_bots_for_author(&settings, author_harness.as_deref());
3554 let mut required_reviewers = settings.reviewers.clone();
3555 for reviewer in resolved_local_peer_reviewers_for_author(&settings, author_harness.as_deref()) {
3556 if !required_reviewers.contains(&reviewer) {
3557 required_reviewers.push(reviewer);
3558 }
3559 }
3560 let optional_bots = resolved_optional_bots(&settings);
3561 let nudge_configs = resolved_nudge_configs(&settings);
3562
3563 let now: DateTime<Utc> = if let Some(ref s) = parsed.now_override {
3565 s.parse().unwrap_or_else(|_| Utc::now())
3566 } else {
3567 Utc::now()
3568 };
3569
3570 let session_id = manifest
3571 .session_id
3572 .clone()
3573 .unwrap_or_else(|| "unknown".to_string());
3574 let emit = |event_type: &str, data: serde_json::Value| {
3575 emit_to_both(&project_events, &global_events, event_type, data);
3576 };
3577
3578 if check_cancel_sentinel(&cwd, &manifest.created_at) {
3580 emit(
3581 "termination",
3582 serde_json::json!({
3583 "session_id": session_id,
3584 "reason": "Interrupted",
3585 "message": "cancel sentinel present"
3586 }),
3587 );
3588 return (
3589 0,
3590 allow_output(
3591 "allow",
3592 Some(TerminationReason::Interrupted),
3593 "cancel sentinel present; exiting",
3594 0,
3595 None,
3596 ),
3597 );
3598 }
3599
3600 if let Some(ref status) = manifest.legacy_status {
3602 emit(
3603 "loop_check_legacy_manifest",
3604 serde_json::json!({
3605 "session_id": session_id,
3606 "status": status
3607 }),
3608 );
3609 return (
3610 0,
3611 allow_output(
3612 "allow",
3613 None,
3614 &format!("legacy manifest status={status}; allowing exit"),
3615 0,
3616 None,
3617 ),
3618 );
3619 }
3620
3621 if let Some(trip) = check_budget(&manifest, &settings, &now, &ledger_path) {
3623 let axis = match &trip {
3624 BudgetTrip::WallClock => "wall_clock",
3625 BudgetTrip::Cost => "cost",
3626 };
3627 emit(
3628 "termination",
3629 serde_json::json!({
3630 "session_id": session_id,
3631 "reason": "Budget",
3632 "axis": axis,
3633 "message": format!("budget exceeded (axis={axis})")
3634 }),
3635 );
3636 return (
3637 0,
3638 allow_output(
3639 "allow",
3640 Some(TerminationReason::Budget),
3641 &format!("budget exceeded (axis={axis})"),
3642 0,
3643 None,
3644 ),
3645 );
3646 }
3647
3648 let generic = crate::delivery_completion::evaluate_manifest(
3649 &cwd,
3650 manifest.plan_path.as_deref(),
3651 &project_events,
3652 );
3653 let gh_bin = &parsed.gh_bin;
3658 let gh_available = {
3659 match Command::new(gh_bin).arg("--version").output() {
3663 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
3664 Err(_) => false,
3665 Ok(_) => true, }
3667 };
3668
3669 if !gh_available
3670 && matches!(
3671 generic,
3672 crate::delivery_completion::DeliveryCompletion::Inactive
3673 )
3674 {
3675 if !manifest.attended && !manifest.advisory {
3676 emit(
3678 "termination",
3679 serde_json::json!({
3680 "session_id": session_id,
3681 "reason": "Interrupted",
3682 "message": "gh binary not found; unattended sessions require gh"
3683 }),
3684 );
3685 return (
3686 0,
3687 allow_output(
3688 "allow",
3689 Some(TerminationReason::Interrupted),
3690 "gh binary not found; unattended sessions require gh",
3691 0,
3692 None,
3693 ),
3694 );
3695 }
3696 emit(
3701 "loop_advisory_mode",
3702 serde_json::json!({
3703 "session_id": session_id,
3704 "attended": manifest.attended
3705 }),
3706 );
3707 let (advisory_intent, _advisory_intent_source) =
3708 detect_intent(last_assistant_message.as_deref(), &transcript_path);
3709 if let Intent::Aborted { ref reason } = advisory_intent {
3710 emit(
3711 "termination",
3712 serde_json::json!({
3713 "session_id": session_id,
3714 "reason": "Aborted",
3715 "message": reason
3716 }),
3717 );
3718 return (
3719 0,
3720 allow_output(
3721 "allow",
3722 Some(TerminationReason::Aborted),
3723 "aborted tag detected (advisory mode)",
3724 0,
3725 None,
3726 ),
3727 );
3728 }
3729 if advisory_intent == Intent::Promise {
3730 emit(
3731 "termination",
3732 serde_json::json!({
3733 "session_id": session_id,
3734 "reason": "DoneAdvisory",
3735 "message": "promise accepted in advisory mode (gh unavailable)"
3736 }),
3737 );
3738 return (
3739 0,
3740 allow_output(
3741 "allow",
3742 Some(TerminationReason::DoneAdvisory),
3743 "promise accepted in advisory mode (gh unavailable)",
3744 0,
3745 None,
3746 ),
3747 );
3748 }
3749 return (
3750 0,
3751 allow_output(
3752 "block",
3753 None,
3754 "gh binary not found; running in advisory mode (promise + budget only)",
3755 0,
3756 None,
3757 ),
3758 );
3759 }
3760
3761 let (intent, intent_source) =
3763 detect_intent(last_assistant_message.as_deref(), &transcript_path);
3764 let git_bin = &parsed.git_bin;
3765 let head_sha = git_head_sha(git_bin, &cwd);
3766
3767 let backstop_n: u64 = if manifest.attended { 5 } else { 3 };
3771
3772 let fp_read_result = Command::new(gh_bin)
3778 .args(["pr", "view", "--json", "state,number,headRefName"])
3779 .current_dir(&cwd)
3780 .output();
3781 let (fp_pr_state, fp_ci, fp_review_ts, fp_read_failed) = match fp_read_result {
3782 Ok(o) if o.status.success() => {
3783 let pv: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
3784 let state =
3785 PrState::from_gh_str(pv.get("state").and_then(|v| v.as_str()).unwrap_or("none"));
3786
3787 let ci = match Command::new(gh_bin)
3789 .args(["pr", "checks", "--json", "name,state,bucket"])
3790 .current_dir(&cwd)
3791 .output()
3792 {
3793 Ok(co) if co.status.success() => {
3794 let cv: Value = serde_json::from_slice(&co.stdout).unwrap_or(Value::Null);
3795 compute_ci_conclusion(&cv).unwrap_or(CiConclusion::None)
3796 }
3797 _ => CiConclusion::None,
3798 };
3799
3800 let rv_ts = if !manifest.no_external && !required_bots.is_empty() {
3803 match Command::new(gh_bin)
3804 .args(["pr", "view", "--json", "reviews,comments"])
3805 .current_dir(&cwd)
3806 .output()
3807 {
3808 Ok(ro) if ro.status.success() => {
3809 let rv: Value = serde_json::from_slice(&ro.stdout).unwrap_or(Value::Null);
3810 compute_review_info(&rv, &required_bots).latest_ts
3811 }
3812 _ => "none".to_string(),
3813 }
3814 } else {
3815 "none".to_string()
3816 };
3817
3818 (state, ci, rv_ts, false)
3819 }
3820 Ok(o) if is_no_pr_stderr(&o.stderr) => {
3824 (PrState::None, CiConclusion::None, "none".to_string(), false)
3825 }
3826 _ => (PrState::None, CiConclusion::None, "none".to_string(), true),
3829 };
3830
3831 let tentative_fp = generic.delivery_fingerprint(make_fingerprint(
3833 &head_sha,
3834 fp_pr_state.as_str(),
3835 &fp_ci.render(),
3836 &fp_review_ts,
3837 ));
3838
3839 let min_fire_gap = min_fire_gap_secs();
3842 let (prior_fires, consecutive_unchanged, last_recorded_fp, streak_window) = read_prior_fires(
3843 &project_events,
3844 &session_id,
3845 &tentative_fp,
3846 now,
3847 min_fire_gap,
3848 );
3849
3850 let fingerprint = if fp_read_failed && !generic.is_active() {
3853 last_recorded_fp.unwrap_or(tentative_fp)
3854 } else {
3855 tentative_fp
3856 };
3857
3858 let (consecutive_unchanged, streak_window) = if fp_read_failed && !generic.is_active() {
3861 let (_, streak, _, window) = read_prior_fires(
3863 &project_events,
3864 &session_id,
3865 &fingerprint,
3866 now,
3867 min_fire_gap,
3868 );
3869 (streak, window)
3870 } else {
3871 (consecutive_unchanged, streak_window)
3872 };
3873
3874 let this_fire = prior_fires + 1;
3875 let consecutive_after = if fp_read_failed {
3879 consecutive_unchanged
3880 } else {
3881 consecutive_unchanged + 1
3882 };
3883
3884 let backstop_tripped = consecutive_after >= backstop_n;
3885
3886 const MUTE_PROBE_N: u64 = 2;
3895
3896 let node_id = scan_manifest_field(&manifest_content, "graph_node_id").or_else(|| {
3897 scan_manifest_field(&manifest_content, "target_claim_key")
3898 .and_then(|k| k.strip_prefix("node:").map(|s| s.to_string()))
3899 });
3900 let (open_findings, malformed_findings) = match node_id.as_deref() {
3901 Some(n) => open_review_findings(&project_events, n),
3902 None => (Vec::new(), 0),
3903 };
3904 if malformed_findings > 0 {
3905 emit(
3906 "loop_check_malformed_finding",
3907 serde_json::json!({
3908 "session_id": session_id,
3909 "node": node_id,
3910 "malformed_lines": malformed_findings
3911 }),
3912 );
3913 }
3914
3915 if generic.is_active()
3917 || intent != Intent::None
3918 || backstop_tripped
3919 || consecutive_after >= MUTE_PROBE_N
3920 {
3921 if let Intent::Aborted { ref reason } = intent {
3923 emit(
3924 "termination",
3925 serde_json::json!({
3926 "session_id": session_id,
3927 "reason": "Aborted",
3928 "message": reason
3929 }),
3930 );
3931 emit(
3932 "loop_check",
3933 serde_json::json!({
3934 "session_id": session_id,
3935 "fingerprint": fingerprint,
3936 "fires": this_fire,
3937 "consecutive_unchanged": consecutive_after,
3938 "streak_window_secs": streak_window,
3939 "decision": "allow",
3940 "intent": "aborted",
3941 "intent_source": intent_source,
3942 "pr_state": fp_pr_state.as_str(),
3943 "ci": fp_ci.render(),
3944 "reviewed": false,
3945 "fp_read_failed": fp_read_failed
3946 }),
3947 );
3948 return (
3949 0,
3950 allow_output(
3951 "allow",
3952 Some(TerminationReason::Aborted),
3953 "aborted tag detected",
3954 this_fire,
3955 Some(fingerprint),
3956 ),
3957 );
3958 }
3959
3960 if !open_findings.is_empty()
3971 && !backstop_tripped
3972 && (intent == Intent::Promise || consecutive_after >= MUTE_PROBE_N)
3973 {
3974 let reason = build_findings_block_reason(&open_findings, malformed_findings);
3975 emit(
3976 "loop_check",
3977 serde_json::json!({
3978 "session_id": session_id,
3979 "fingerprint": fingerprint,
3980 "fires": this_fire,
3981 "consecutive_unchanged": consecutive_after,
3982 "streak_window_secs": streak_window,
3983 "decision": "block",
3984 "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
3985 "intent_source": intent_source,
3986 "pr_state": fp_pr_state.as_str(),
3987 "ci": fp_ci.render(),
3988 "reviewed": false,
3989 "open_findings": open_findings.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
3990 "malformed_findings": malformed_findings,
3991 "fp_read_failed": fp_read_failed
3992 }),
3993 );
3994 return (
3995 0,
3996 allow_output("block", None, &reason, this_fire, Some(fingerprint)),
3997 );
3998 }
3999
4000 if let Some(output) = crate::delivery_completion::gate_output(
4001 &generic,
4002 intent == Intent::Promise,
4003 &project_events,
4004 &global_events,
4005 &session_id,
4006 manifest.session_id.as_deref(),
4007 node_id.as_deref(),
4008 intent_source,
4009 &fingerprint,
4010 this_fire,
4011 backstop_tripped,
4012 consecutive_after,
4013 streak_window,
4014 fp_pr_state.as_str(),
4015 &fp_ci.render(),
4016 ) {
4017 return (0, output);
4018 }
4019
4020 if manifest.planned && intent == Intent::Promise {
4025 emit(
4026 "termination",
4027 serde_json::json!({
4028 "session_id": session_id,
4029 "reason": "DonePlanned",
4030 "message": "promise in plan-only unit"
4031 }),
4032 );
4033 emit(
4034 "loop_check",
4035 serde_json::json!({
4036 "session_id": session_id,
4037 "fingerprint": fingerprint,
4038 "fires": this_fire,
4039 "consecutive_unchanged": consecutive_after,
4040 "streak_window_secs": streak_window,
4041 "decision": "allow",
4042 "intent": "promise",
4043 "intent_source": intent_source,
4044 "pr_state": fp_pr_state.as_str(),
4045 "ci": fp_ci.render(),
4046 "reviewed": true,
4047 "fp_read_failed": fp_read_failed
4048 }),
4049 );
4050 return (
4051 0,
4052 allow_output(
4053 "allow",
4054 Some(TerminationReason::DonePlanned),
4055 "promise + plan-only unit; done",
4056 this_fire,
4057 Some(fingerprint),
4058 ),
4059 );
4060 }
4061
4062 if (manifest.no_ship || manifest.advisory) && intent == Intent::Promise {
4064 emit(
4065 "termination",
4066 serde_json::json!({
4067 "session_id": session_id,
4068 "reason": "DoneAdvisory",
4069 "message": "promise in advisory/no_ship unit"
4070 }),
4071 );
4072 emit(
4073 "loop_check",
4074 serde_json::json!({
4075 "session_id": session_id,
4076 "fingerprint": fingerprint,
4077 "fires": this_fire,
4078 "consecutive_unchanged": consecutive_after,
4079 "streak_window_secs": streak_window,
4080 "decision": "allow",
4081 "intent": "promise",
4082 "intent_source": intent_source,
4083 "pr_state": fp_pr_state.as_str(),
4084 "ci": fp_ci.render(),
4085 "reviewed": true,
4086 "fp_read_failed": fp_read_failed
4087 }),
4088 );
4089 return (
4090 0,
4091 allow_output(
4092 "allow",
4093 Some(TerminationReason::DoneAdvisory),
4094 "promise + advisory unit; done",
4095 this_fire,
4096 Some(fingerprint),
4097 ),
4098 );
4099 }
4100
4101 if manifest.batched && intent == Intent::Promise {
4112 emit(
4113 "termination",
4114 serde_json::json!({
4115 "session_id": session_id,
4116 "reason": "DoneBatched",
4117 "message": "promise in batched unit; commit landed on shared branch"
4118 }),
4119 );
4120 emit(
4121 "loop_check",
4122 serde_json::json!({
4123 "session_id": session_id,
4124 "fingerprint": fingerprint,
4125 "fires": this_fire,
4126 "consecutive_unchanged": consecutive_after,
4127 "streak_window_secs": streak_window,
4128 "decision": "allow",
4129 "intent": "promise",
4130 "intent_source": intent_source,
4131 "pr_state": fp_pr_state.as_str(),
4132 "ci": fp_ci.render(),
4133 "reviewed": true,
4134 "fp_read_failed": fp_read_failed
4135 }),
4136 );
4137 return (
4138 0,
4139 allow_output(
4140 "allow",
4141 Some(TerminationReason::DoneBatched),
4142 "promise + batched unit; commit on shared branch, batch PR ships it",
4143 this_fire,
4144 Some(fingerprint),
4145 ),
4146 );
4147 }
4148
4149 let done_result = run_done(
4151 gh_bin,
4152 &cwd,
4153 settings.ci_declared_none,
4154 manifest.no_external,
4155 &required_bots,
4156 &optional_bots,
4157 &settings.external_reviewers,
4158 &required_reviewers,
4159 &nudge_configs,
4160 &head_sha,
4161 &project_events,
4162 );
4163
4164 match done_result {
4165 Ok(mut pr_info) => {
4166 let (fingerprint, consecutive_after, streak_window) = if !fp_read_failed {
4176 let done_fp = make_fingerprint(
4177 &head_sha,
4178 fp_pr_state.as_str(),
4179 &fp_ci.render(),
4180 &max_ts(&fp_review_ts, &pr_info.latest_review_ts),
4181 );
4182 if done_fp != fingerprint {
4183 let (_, streak, _, window) = read_prior_fires(
4184 &project_events,
4185 &session_id,
4186 &done_fp,
4187 now,
4188 min_fire_gap,
4189 );
4190 (done_fp, streak + 1, window)
4191 } else {
4192 (fingerprint, consecutive_after, streak_window)
4193 }
4194 } else {
4195 (fingerprint, consecutive_after, streak_window)
4196 };
4197 let backstop_tripped = consecutive_after >= backstop_n;
4198
4199 let nudge_pr_number = pr_info.number;
4207 for n in pr_info.bot_nudges.iter_mut() {
4208 if n.class != NudgeClass::NeedsNudge {
4209 continue;
4210 }
4211 if post_nudge_comment(gh_bin, &cwd, nudge_pr_number, &n.review_handle) {
4212 emit(
4213 "loop_check_nudge_posted",
4214 serde_json::json!({
4215 "session_id": session_id,
4216 "pr": nudge_pr_number,
4217 "bot": n.login,
4218 "handle": n.review_handle,
4219 "nudge": n.nudges + 1,
4220 "ceiling": n.ceiling
4221 }),
4222 );
4223 n.nudges += 1;
4224 n.newest_age_min = 0;
4225 n.class = NudgeClass::Awaiting;
4226 } else {
4227 emit(
4228 "loop_check_nudge_post_failed",
4229 serde_json::json!({
4230 "session_id": session_id,
4231 "pr": nudge_pr_number,
4232 "bot": n.login,
4233 "handle": n.review_handle
4234 }),
4235 );
4236 }
4237 }
4238
4239 let ci_ok = pr_info.ci_conclusion.is_ok();
4240 let pr_open = pr_info.state.is_open_or_merged();
4241 let head_shipped = !pr_info.head_oid.is_empty() && pr_info.head_oid == head_sha;
4247
4248 let (mut probe_block, mut probe_results) = (None, Value::Null);
4252 if pr_open && ci_ok && pr_info.reviewed && head_shipped {
4253 match evaluate_done_probes(
4254 manifest.plan_path.as_deref(),
4255 settings.done_probes.as_ref(),
4256 &cwd,
4257 &project_events,
4258 &session_id,
4259 PROBE_TIMEOUT,
4260 ) {
4261 ProbeGate::Absent => {}
4262 ProbeGate::Pass(results) => probe_results = results,
4263 ProbeGate::Fail { reason, results } => {
4264 probe_block = Some(reason);
4265 probe_results = results;
4266 }
4267 }
4268 }
4269
4270 let (reviewed, probes_passed) = (pr_info.reviewed, probe_block.is_none());
4271 if pr_passes(pr_open, ci_ok, reviewed, head_shipped, probes_passed) {
4272 let done_msg = if pr_info.usage_limited.is_empty() {
4276 format!("PR #{} is green and reviewed", pr_info.number)
4277 } else {
4278 format!(
4279 "PR #{} is green and reviewed (rate-limited, dropped from gate: {})",
4280 pr_info.number,
4281 pr_info.usage_limited.join(", ")
4282 )
4283 };
4284 emit(
4285 "termination",
4286 serde_json::json!({
4287 "session_id": session_id,
4288 "reason": "DonePRGreen",
4289 "message": done_msg.clone()
4290 }),
4291 );
4292 emit(
4293 "loop_check",
4294 serde_json::json!({
4295 "session_id": session_id,
4296 "fingerprint": fingerprint,
4297 "fires": this_fire,
4298 "consecutive_unchanged": consecutive_after,
4299 "streak_window_secs": streak_window,
4300 "decision": "allow",
4301 "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
4302 "intent_source": intent_source,
4303 "pr_state": pr_info.state.as_str(),
4304 "ci": pr_info.ci_conclusion.render(),
4305 "reviewed": pr_info.reviewed,
4306 "review_skipped": pr_info.review_skipped,
4307 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4308 "fp_read_failed": fp_read_failed,
4309 "done_probes": probe_results
4310 }),
4311 );
4312 return (
4313 0,
4314 allow_output(
4315 "allow",
4316 Some(TerminationReason::DonePRGreen),
4317 &done_msg,
4318 this_fire,
4319 Some(fingerprint),
4320 ),
4321 );
4322 }
4323
4324 if pr_open
4348 && pr_info.reviewed
4349 && head_shipped
4350 && !ci_ok
4351 && !pr_info.ci_has_pending
4352 && pr_info.mergeable != "CONFLICTING"
4353 {
4354 if let Some(main_failing) =
4355 main_head_failing_checks(gh_bin, &cwd, MAIN_RUN_LOOKBACK)
4356 {
4357 if is_pre_existing_main_red(&pr_info.failing_checks, &main_failing) {
4358 let proof = format!(
4359 "same checks red on main (last {} completed runs): {}",
4360 MAIN_RUN_LOOKBACK,
4361 pr_info.failing_checks.join(", ")
4362 );
4363 let msg = format!(
4364 "PR #{} complete and reviewed; awaiting merge past pre-existing main-red ({proof})",
4365 pr_info.number
4366 );
4367 if !already_emitted_awaiting_merge(&project_events, &session_id) {
4372 emit(
4373 "termination",
4374 serde_json::json!({
4375 "session_id": session_id,
4376 "reason": "DoneAwaitingMerge",
4377 "message": msg.clone()
4378 }),
4379 );
4380 emit(
4381 "loop_check",
4382 serde_json::json!({
4383 "session_id": session_id,
4384 "fingerprint": fingerprint,
4385 "fires": this_fire,
4386 "consecutive_unchanged": consecutive_after,
4387 "streak_window_secs": streak_window,
4388 "decision": "allow",
4389 "intent": if intent == Intent::Promise { "promise" } else { "backstop" },
4390 "intent_source": intent_source,
4391 "pr_state": pr_info.state.as_str(),
4392 "ci": pr_info.ci_conclusion.render(),
4393 "reviewed": pr_info.reviewed,
4394 "review_skipped": pr_info.review_skipped,
4395 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4396 "fp_read_failed": fp_read_failed
4397 }),
4398 );
4399 best_effort_notify(
4400 &format!(
4401 "PR #{} ready - merge past pre-existing main-red",
4402 pr_info.number
4403 ),
4404 &msg,
4405 );
4406 }
4407 return (
4408 0,
4409 allow_output(
4410 "allow",
4411 Some(TerminationReason::DoneAwaitingMerge),
4412 &msg,
4413 this_fire,
4414 Some(fingerprint),
4415 ),
4416 );
4417 }
4418 }
4419 }
4420
4421 if let Intent::Watching {
4431 ref reason,
4432 ref timeout,
4433 ..
4434 } = intent
4435 {
4436 let blocker = if harness_can_idle(
4442 author_harness.as_deref(),
4443 std::env::var("FNO_DRIVER_LIB").is_ok(),
4444 ) {
4445 async_wait_class(&pr_info, &head_sha, open_findings.is_empty())
4446 } else {
4447 None
4448 };
4449 if let Some(blocker) = blocker {
4450 let window_ms = watch_window_ms(timeout.as_deref());
4456 let renewed = match (
4457 scan_manifest_field(&manifest_content, "target_claim_key"),
4458 scan_manifest_field(&manifest_content, "target_claim_holder"),
4459 ) {
4460 (Some(key), Some(holder)) => matches!(
4461 crate::claims::renew(&key, &holder, window_ms, None),
4462 Ok(true)
4463 ),
4464 _ => false,
4465 };
4466 if renewed {
4467 emit(
4468 "loop_check_watch_idle",
4469 serde_json::json!({
4470 "session_id": session_id,
4471 "pr": pr_info.number,
4472 "blocker": blocker,
4473 "declared_timeout": timeout.clone().unwrap_or_default(),
4474 "reason": reason,
4475 "lease_ms": window_ms
4476 }),
4477 );
4478 emit(
4479 "loop_check",
4480 serde_json::json!({
4481 "session_id": session_id,
4482 "fingerprint": fingerprint,
4483 "fires": this_fire,
4484 "consecutive_unchanged": consecutive_after,
4485 "streak_window_secs": streak_window,
4486 "decision": "allow",
4487 "intent": "watching",
4488 "intent_source": intent_source,
4489 "pr_state": pr_info.state.as_str(),
4490 "ci": pr_info.ci_conclusion.render(),
4491 "reviewed": pr_info.reviewed,
4492 "review_skipped": pr_info.review_skipped,
4493 "fp_read_failed": fp_read_failed
4494 }),
4495 );
4496 let msg = format!(
4497 "watching: idling until watcher fires (PR #{}, {blocker} pending)",
4498 pr_info.number
4499 );
4500 return (
4501 0,
4502 allow_output("allow", None, &msg, this_fire, Some(fingerprint)),
4503 );
4504 }
4505 }
4508 }
4512
4513 let sole_blocker_is_awaiting = pr_open
4526 && ci_ok
4527 && probe_block.is_none()
4528 && !pr_info.reviewed
4529 && pr_info.unattested_reviewers.is_empty()
4530 && pr_info.unaddressed_findings.is_empty()
4531 && pr_info
4532 .bot_nudges
4533 .iter()
4534 .any(|n| n.class == NudgeClass::Awaiting);
4535 if backstop_tripped
4540 && (!pr_open || !ci_ok || !pr_info.reviewed || probe_block.is_some())
4541 && !sole_blocker_is_awaiting
4542 {
4543 let nudge_giveup = unresponsive_bot(&pr_info);
4550 let noprogress_msg = match nudge_giveup {
4551 Some(n) => nudge_giveup_message(n),
4552 None => format!(
4553 "fingerprint unchanged for {} consecutive fires over {}m; PR not done",
4554 consecutive_after,
4555 streak_window / 60
4556 ),
4557 };
4558 if let Some(n) = nudge_giveup {
4559 best_effort_notify(
4560 "target: bot review gave up",
4561 &format!(
4562 "PR #{}: {} did not review after {} nudges over {}m",
4563 pr_info.number, n.login, n.nudges, n.span_min
4564 ),
4565 );
4566 }
4567 emit(
4569 "termination",
4570 serde_json::json!({
4571 "session_id": session_id,
4572 "reason": "NoProgress",
4573 "message": noprogress_msg
4574 }),
4575 );
4576 emit(
4577 "loop_check",
4578 serde_json::json!({
4579 "session_id": session_id,
4580 "fingerprint": fingerprint,
4581 "fires": this_fire,
4582 "consecutive_unchanged": consecutive_after,
4583 "streak_window_secs": streak_window,
4584 "decision": "allow",
4585 "intent": "backstop",
4586 "intent_source": intent_source,
4587 "pr_state": pr_info.state.as_str(),
4588 "ci": pr_info.ci_conclusion.render(),
4589 "reviewed": pr_info.reviewed,
4590 "review_skipped": pr_info.review_skipped,
4591 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4592 "fp_read_failed": fp_read_failed,
4593 "done_probes": probe_results
4594 }),
4595 );
4596 let return_msg = match nudge_giveup {
4597 Some(_) => noprogress_msg.clone(),
4598 None => format!(
4599 "fingerprint unchanged for {} fires over {}m; HEAD={}, PR={}, CI={}, reviewed={}",
4600 consecutive_after,
4601 streak_window / 60,
4602 short_sha(&head_sha),
4603 pr_info.state.as_str(),
4604 pr_info.ci_conclusion.render(),
4605 pr_info.reviewed
4606 ),
4607 };
4608 return (
4609 0,
4610 allow_output(
4611 "allow",
4612 Some(TerminationReason::NoProgress),
4613 &return_msg,
4614 this_fire,
4615 Some(fingerprint),
4616 ),
4617 );
4618 }
4619
4620 let reason = crate::nudge::append_inbox_nudge(
4625 &probe_block.clone().unwrap_or_else(|| {
4626 build_block_reason(&pr_info, &head_sha, open_findings.is_empty())
4627 }),
4628 &cwd,
4629 &session_id,
4630 );
4631 emit(
4632 "loop_check",
4633 serde_json::json!({
4634 "session_id": session_id,
4635 "fingerprint": fingerprint,
4636 "fires": this_fire,
4637 "consecutive_unchanged": consecutive_after,
4638 "streak_window_secs": streak_window,
4639 "decision": "block",
4640 "intent": if intent == Intent::Promise { "promise" } else { "none" },
4641 "intent_source": intent_source,
4642 "pr_state": pr_info.state.as_str(),
4643 "ci": pr_info.ci_conclusion.render(),
4644 "reviewed": pr_info.reviewed,
4645 "review_skipped": pr_info.review_skipped,
4646 "unaddressed_blocking": pr_info.unaddressed_findings.len(),
4647 "fp_read_failed": fp_read_failed,
4648 "done_probes": probe_results
4649 }),
4650 );
4651 return (
4652 0,
4653 allow_output("block", None, &reason, this_fire, Some(fingerprint)),
4654 );
4655 }
4656 Err((failed_read, failed_stderr)) => {
4657 emit(
4666 "loop_check_gh_error",
4667 serde_json::json!({
4668 "session_id": session_id,
4669 "read": failed_read,
4670 "stderr_tail": failed_stderr
4671 }),
4672 );
4673 emit(
4674 "loop_check",
4675 serde_json::json!({
4676 "session_id": session_id,
4677 "fingerprint": fingerprint,
4678 "fires": this_fire,
4679 "consecutive_unchanged": consecutive_after,
4680 "streak_window_secs": streak_window,
4681 "decision": "block",
4682 "intent": if intent == Intent::Promise { "promise" } else { "none" },
4683 "intent_source": intent_source,
4684 "pr_state": "unknown",
4685 "ci": "unknown",
4686 "reviewed": false,
4687 "fp_read_failed": true
4688 }),
4689 );
4690 return (
4691 0,
4692 allow_output(
4693 "block",
4694 None,
4695 &format!("gh read '{failed_read}' failed; retrying next fire"),
4696 this_fire,
4697 Some(fingerprint),
4698 ),
4699 );
4700 }
4701 }
4702 }
4703
4704 emit(
4706 "loop_check",
4707 serde_json::json!({
4708 "session_id": session_id,
4709 "fingerprint": fingerprint,
4710 "fires": this_fire,
4711 "consecutive_unchanged": consecutive_after,
4712 "streak_window_secs": streak_window,
4713 "decision": "block",
4714 "intent": "none",
4715 "intent_source": intent_source,
4716 "pr_state": fp_pr_state.as_str(),
4717 "ci": fp_ci.render(),
4718 "reviewed": false,
4719 "fp_read_failed": fp_read_failed
4720 }),
4721 );
4722
4723 let continue_msg = crate::nudge::append_inbox_nudge(
4726 "continue working; no completion signal. If you are only waiting on an async check (CI/review) with nothing to do, arm a harness-tracked watcher with a hard timeout (e.g. background Bash `gh pr checks <N> --watch & w=$!; (sleep 1800; kill $w 2>/dev/null) & wait $w`) and end your turn with `<watching reason=\"ci|review\" pr=\"<N>\" timeout=\"30m\">` - the session idles until the watcher exits instead of re-waking every tick.",
4727 &cwd,
4728 &session_id,
4729 );
4730 (
4731 0,
4732 allow_output("block", None, &continue_msg, this_fire, Some(fingerprint)),
4733 )
4734}
4735
4736#[allow(clippy::too_many_arguments)]
4737fn run_done(
4738 gh_bin: &str,
4739 cwd: &Path,
4740 ci_declared_none: bool,
4741 no_external: bool,
4742 required_bots: &[String],
4743 optional_bots: &[String],
4744 external_reviewers: &[String],
4745 reviewers: &[String],
4746 nudge_configs: &[NudgeConfig],
4747 head_sha: &str,
4748 events_path: &Path,
4749) -> Result<PrInfo, (String, String)> {
4750 read_pr_info(
4751 gh_bin,
4752 cwd,
4753 ci_declared_none,
4754 no_external,
4755 required_bots,
4756 optional_bots,
4757 external_reviewers,
4758 reviewers,
4759 nudge_configs,
4760 head_sha,
4761 events_path,
4762 )
4763}
4764
4765const WATCH_SLACK_MS: i64 = 12 * 60_000;
4769
4770fn watch_window_ms(timeout: Option<&str>) -> i64 {
4774 let declared = timeout
4775 .and_then(crate::claims::parse_ttl_ms)
4776 .unwrap_or(30 * 60_000);
4777 declared.clamp(5 * 60_000, 2 * 3_600_000) + WATCH_SLACK_MS
4778}
4779
4780fn harness_can_idle(author_harness: Option<&str>, is_loop_run_child: bool) -> bool {
4790 author_harness == Some("claude") && !is_loop_run_child
4791}
4792
4793fn async_wait_class(
4799 pr: &PrInfo,
4800 local_head: &str,
4801 open_findings_empty: bool,
4802) -> Option<&'static str> {
4803 let head_shipped = !pr.head_oid.is_empty() && pr.head_oid == local_head;
4804 if pr.state != PrState::Open
4805 || !head_shipped
4806 || !pr.unaddressed_findings.is_empty()
4807 || !open_findings_empty
4808 {
4809 return None;
4810 }
4811 if pr.ci_has_pending && !matches!(pr.ci_conclusion, CiConclusion::Failure(_)) {
4815 return Some("ci");
4816 }
4817 if pr.ci_conclusion.is_ok()
4836 && !pr.reviewed
4837 && !pr.review_skipped
4838 && !pr.missing_bots.is_empty()
4839 && pr.unattested_reviewers.is_empty()
4840 && pr.bot_nudges.iter().all(|n| nudge_class_idlable(&n.class))
4841 {
4842 return Some("review");
4843 }
4844 None
4845}
4846
4847fn short_sha(s: &str) -> String {
4851 s.chars().take(8).collect()
4852}
4853
4854fn arm_watch_hint(pr_number: i64, blocker: &str) -> String {
4868 let watcher = if blocker == "review" {
4873 format!(
4874 "background Bash `n=$(gh pr view {pr_number} --json reviews --jq '.reviews|length'); i=0; while [ $i -lt 30 ]; do sleep 60; [ \"$(gh pr view {pr_number} --json reviews --jq '.reviews|length')\" -gt \"$n\" ] && break; i=$((i+1)); done` (wakes when a new review posts, or after ~30m)"
4875 )
4876 } else {
4877 format!(
4878 "background Bash `gh pr checks {pr_number} --watch & w=$!; (sleep 1800; kill $w 2>/dev/null) & k=$!; wait $w; kill $k 2>/dev/null`"
4879 )
4880 };
4881 format!(
4882 " Arm a harness-tracked watcher with a hard timeout (e.g. {watcher}), then end your turn with `<watching reason=\"{blocker}\" pr=\"{pr_number}\" timeout=\"30m\">` and nothing else - the session then idles until the watcher exits."
4883 )
4884}
4885
4886const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
4898
4899const PROBE_CAP: usize = 3;
4901
4902const PROBE_STDERR_CAP: usize = 500;
4905
4906enum ProbeOutcome {
4907 Pass,
4908 Fail { code: Option<i32>, stderr: String },
4909 Timeout,
4910}
4911
4912impl ProbeOutcome {
4913 fn render(&self) -> String {
4915 match self {
4916 ProbeOutcome::Pass => "pass".to_string(),
4917 ProbeOutcome::Fail { code: Some(c), .. } => format!("fail:{c}"),
4918 ProbeOutcome::Fail { code: None, .. } => "fail:signal".to_string(),
4919 ProbeOutcome::Timeout => "timeout".to_string(),
4920 }
4921 }
4922}
4923
4924enum ProbeGate {
4925 Absent,
4927 Pass(Value),
4928 Fail {
4929 reason: String,
4930 results: Value,
4931 },
4932}
4933
4934fn unquote_scalar(s: &str) -> String {
4942 let s = s.trim();
4943 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
4944 let inner = &s[1..s.len() - 1];
4945 let mut out = String::with_capacity(inner.len());
4946 let mut chars = inner.chars();
4947 while let Some(c) = chars.next() {
4948 if c != '\\' {
4949 out.push(c);
4950 continue;
4951 }
4952 match chars.next() {
4953 Some('n') => out.push('\n'),
4954 Some('t') => out.push('\t'),
4955 Some('r') => out.push('\r'),
4956 Some('0') => out.push('\0'),
4957 Some(other) => out.push(other),
4959 None => out.push('\\'),
4960 }
4961 }
4962 return out;
4963 }
4964 if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
4965 return s[1..s.len() - 1].replace("''", "'");
4967 }
4968 s.to_string()
4969}
4970
4971fn split_inline_list(body: &str) -> Vec<String> {
4975 let mut out = Vec::new();
4976 let mut chars = body.chars().peekable();
4977 while let Some(c) = chars.next() {
4978 if c == '"' || c == '\'' {
4979 let mut item = String::new();
4980 let mut escaped = false;
4981 for c2 in chars.by_ref() {
4982 if escaped {
4983 item.push(c2);
4984 escaped = false;
4985 } else if c2 == '\\' {
4986 escaped = true;
4987 } else if c2 == c {
4988 break;
4989 } else {
4990 item.push(c2);
4991 }
4992 }
4993 out.push(item);
4994 }
4995 }
4996 if out.is_empty() {
4997 out = body
4998 .split(',')
4999 .map(unquote_scalar)
5000 .filter(|s| !s.is_empty())
5001 .collect();
5002 }
5003 out
5004}
5005
5006#[derive(Debug, PartialEq)]
5008enum ProbeDecl {
5009 None,
5011 Probes(Vec<String>),
5012 Unparseable,
5017}
5018
5019fn parse_done_probes(content: &str) -> ProbeDecl {
5023 let content = content.trim_start();
5024 if !content.starts_with("---") {
5025 return ProbeDecl::None;
5026 }
5027 let after_first = &content[3..];
5028 let Some(end) = after_first.find("\n---") else {
5029 return ProbeDecl::None;
5030 };
5031
5032 let mut out = Vec::new();
5033 let mut declared = false;
5034 let mut in_block = false;
5035 for line in after_first[..end].lines() {
5036 let trimmed = line.trim();
5037 if !in_block {
5038 let Some(rest) = trimmed.strip_prefix("done_probes:") else {
5039 continue;
5040 };
5041 declared = true;
5042 let rest = rest.trim();
5043 if rest == "[]" {
5044 return ProbeDecl::None;
5045 }
5046 if let Some(inner) = rest.strip_prefix('[') {
5047 let Some(inner) = inner.strip_suffix(']') else {
5051 return ProbeDecl::Unparseable;
5052 };
5053 let items = split_inline_list(inner);
5054 return if items.is_empty() {
5058 ProbeDecl::Unparseable
5059 } else {
5060 ProbeDecl::Probes(items)
5061 };
5062 }
5063 in_block = true;
5064 continue;
5065 }
5066 if trimmed.is_empty() || trimmed.starts_with('#') {
5069 continue;
5070 }
5071 let Some(item) = trimmed.strip_prefix("- ") else {
5072 break; };
5074 let item = unquote_scalar(item);
5075 if !item.is_empty() {
5076 out.push(item);
5077 }
5078 }
5079
5080 match (declared, out.is_empty()) {
5081 (false, _) => ProbeDecl::None,
5082 (true, true) => ProbeDecl::Unparseable,
5083 (true, false) => ProbeDecl::Probes(out),
5084 }
5085}
5086
5087fn keep_last_on_char_boundary(s: &mut String, cap: usize) {
5095 if s.len() <= cap {
5096 return;
5097 }
5098 let start = s.len() - cap;
5099 let cut = (start..=s.len())
5100 .find(|i| s.is_char_boundary(*i))
5101 .unwrap_or(s.len());
5102 s.drain(..cut);
5103}
5104
5105fn killpg(pgid: i32) {
5107 if pgid <= 0 {
5108 return;
5109 }
5110 unsafe {
5113 libc::killpg(pgid, libc::SIGKILL);
5114 }
5115}
5116
5117fn run_probe(cmd: &str, cwd: &Path, timeout: std::time::Duration) -> ProbeOutcome {
5133 use std::os::unix::process::CommandExt;
5134
5135 let spawned = Command::new("sh")
5136 .arg("-c")
5137 .arg(cmd)
5138 .current_dir(cwd)
5139 .stdin(Stdio::null())
5140 .stdout(Stdio::null())
5141 .stderr(Stdio::piped())
5142 .process_group(0)
5143 .spawn();
5144
5145 let mut child = match spawned {
5146 Ok(c) => c,
5147 Err(e) => {
5148 return ProbeOutcome::Fail {
5149 code: Some(127),
5150 stderr: format!("probe spawn failed: {e}"),
5151 }
5152 }
5153 };
5154
5155 let pgid = child.id() as i32;
5157
5158 let mut pipe = child.stderr.take();
5159 let drain = std::thread::spawn(move || {
5160 let mut buf = String::new();
5161 if let Some(ref mut p) = pipe {
5162 let _ = p.read_to_string(&mut buf);
5163 }
5164 buf
5165 });
5166
5167 let start = std::time::Instant::now();
5168 let outcome = loop {
5169 match child.try_wait() {
5170 Ok(Some(status)) => {
5171 break if status.success() {
5172 ProbeOutcome::Pass
5173 } else {
5174 ProbeOutcome::Fail {
5175 code: status.code(),
5176 stderr: String::new(),
5177 }
5178 };
5179 }
5180 Ok(None) => {
5181 if start.elapsed() >= timeout {
5182 kill_process_group(&mut child);
5183 break ProbeOutcome::Timeout;
5184 }
5185 std::thread::sleep(std::time::Duration::from_millis(50));
5186 }
5187 Err(e) => {
5188 kill_process_group(&mut child);
5189 break ProbeOutcome::Fail {
5190 code: None,
5191 stderr: format!("probe wait failed: {e}"),
5192 };
5193 }
5194 }
5195 };
5196
5197 killpg(pgid);
5201
5202 if matches!(outcome, ProbeOutcome::Timeout) {
5206 return outcome;
5207 }
5208
5209 let mut stderr = drain.join().unwrap_or_default();
5210 keep_last_on_char_boundary(&mut stderr, PROBE_STDERR_CAP);
5211 match outcome {
5212 ProbeOutcome::Fail { code, stderr: s } if s.is_empty() => {
5213 ProbeOutcome::Fail { code, stderr }
5214 }
5215 other => other,
5216 }
5217}
5218
5219fn kill_process_group(child: &mut std::process::Child) {
5223 killpg(child.id() as i32);
5224 let _ = child.kill();
5225 let _ = child.wait();
5226}
5227
5228fn undeterminable_marker(cause: &str) -> Value {
5235 serde_json::json!({ "_undeterminable": cause })
5236}
5237
5238fn prior_fires_declared_probes(events_path: &Path, session_id: &str) -> bool {
5242 let Ok(content) = std::fs::read_to_string(events_path) else {
5243 return false;
5244 };
5245 content.lines().any(|line| {
5246 let Ok(val) = serde_json::from_str::<Value>(line) else {
5247 return false;
5248 };
5249 val.get("type").and_then(|v| v.as_str()) == Some("loop_check")
5250 && val.pointer("/data/session_id").and_then(|v| v.as_str()) == Some(session_id)
5251 && val
5252 .pointer("/data/done_probes")
5253 .and_then(|v| v.as_object())
5254 .is_some_and(|m| !m.is_empty())
5255 })
5256}
5257
5258fn plan_declared_probes(
5265 plan_path: Option<&str>,
5266 cwd: &Path,
5267 events_path: &Path,
5268 session_id: &str,
5269) -> Result<Vec<String>, ProbeGate> {
5270 let plan = plan_path.and_then(|p| {
5274 let p = Path::new(p.split('#').next().unwrap_or(p));
5279 let abs = if p.is_absolute() {
5280 p.to_path_buf()
5281 } else {
5282 cwd.join(p)
5283 };
5284 std::fs::read_to_string(abs).ok()
5285 });
5286 let Some(plan) = plan else {
5287 if prior_fires_declared_probes(events_path, session_id) {
5290 return Err(ProbeGate::Fail {
5291 reason: format!(
5292 "done_probes undeterminable: plan {} is unreadable but a prior fire declared probes; restore the plan doc",
5293 plan_path.unwrap_or("(unset)")
5294 ),
5295 results: undeterminable_marker("plan-unreadable"),
5296 });
5297 }
5298 return Ok(Vec::new());
5299 };
5300
5301 let probes = match parse_done_probes(&plan) {
5302 ProbeDecl::None => return Ok(Vec::new()),
5303 ProbeDecl::Unparseable => {
5304 return Err(ProbeGate::Fail {
5305 reason: format!(
5306 "done_probes undeterminable: plan {} declares the field but no probe could be read from it (use a block list, or a single-line inline list)",
5307 plan_path.unwrap_or("(unset)")
5308 ),
5309 results: undeterminable_marker("unparseable-declaration"),
5310 })
5311 }
5312 ProbeDecl::Probes(p) => p,
5313 };
5314 if probes.len() > PROBE_CAP {
5315 return Err(ProbeGate::Fail {
5316 reason: format!(
5317 "plan declares {} done_probes; the cap is {PROBE_CAP} per source (a probe list is a gate, not a test suite)",
5318 probes.len()
5319 ),
5320 results: undeterminable_marker("over-cap"),
5321 });
5322 }
5323 Ok(probes)
5324}
5325
5326fn evaluate_done_probes(
5335 plan_path: Option<&str>,
5336 config_probes: Option<&Result<Vec<String>, String>>,
5337 cwd: &Path,
5338 events_path: &Path,
5339 session_id: &str,
5340 timeout: std::time::Duration,
5341) -> ProbeGate {
5342 let project = match config_probes {
5347 None => Vec::new(),
5348 Some(Err(why)) => {
5349 return ProbeGate::Fail {
5350 reason: format!(
5351 "done_probes undeterminable: config.toml declares `done_probes` but {why}"
5352 ),
5353 results: undeterminable_marker("unparseable-config-declaration"),
5354 }
5355 }
5356 Some(Ok(p)) => p.clone(),
5357 };
5358 if project.len() > PROBE_CAP {
5363 return ProbeGate::Fail {
5364 reason: format!(
5365 "config.toml declares {} done_probes; the cap is {PROBE_CAP} per source (a probe list is a gate, not a test suite)",
5366 project.len()
5367 ),
5368 results: undeterminable_marker("over-cap"),
5369 };
5370 }
5371
5372 let plan_probes = match plan_declared_probes(plan_path, cwd, events_path, session_id) {
5373 Ok(p) => p,
5374 Err(gate) => return gate,
5375 };
5376
5377 if project.is_empty() && plan_probes.is_empty() {
5378 return ProbeGate::Absent;
5379 }
5380
5381 let mut results = serde_json::Map::new();
5382 let mut failures = Vec::new();
5383 for (source, cmd) in project
5384 .iter()
5385 .map(|c| ("project", c))
5386 .chain(plan_probes.iter().map(|c| ("plan", c)))
5387 {
5388 let outcome = run_probe(cmd, cwd, timeout);
5389 results.insert(cmd.clone(), Value::String(outcome.render()));
5394 match &outcome {
5395 ProbeOutcome::Pass => {}
5396 ProbeOutcome::Timeout => failures.push(format!(
5397 "{source} probe `{cmd}` timed out after {}s (killed)",
5398 timeout.as_secs()
5399 )),
5400 ProbeOutcome::Fail { code, stderr } => {
5401 let code = code.map(|c| c.to_string()).unwrap_or("signal".to_string());
5402 let tail = if stderr.trim().is_empty() {
5403 String::new()
5404 } else {
5405 format!(": {}", stderr.trim())
5406 };
5407 failures.push(format!("{source} probe `{cmd}` exited {code}{tail}"));
5408 }
5409 }
5410 }
5411
5412 let results = Value::Object(results);
5413 if failures.is_empty() {
5414 ProbeGate::Pass(results)
5415 } else {
5416 ProbeGate::Fail {
5417 reason: format!(
5418 "done_probes failed - the shipped thing has no evidence of running: {}",
5419 failures.join("; ")
5420 ),
5421 results,
5422 }
5423 }
5424}
5425
5426fn build_block_reason(pr: &PrInfo, local_head: &str, open_findings_empty: bool) -> String {
5427 let idlable = async_wait_class(pr, local_head, open_findings_empty);
5433 let hint = |blocker: &str| -> String {
5434 if idlable == Some(blocker) {
5435 arm_watch_hint(pr.number, blocker)
5436 } else {
5437 String::new()
5438 }
5439 };
5440 if !pr.state.is_open_or_merged() {
5441 return format!(
5442 "no PR for HEAD (pr_state={}); keep working",
5443 pr.state.as_str()
5444 );
5445 }
5446
5447 if !pr.head_oid.is_empty() && pr.head_oid != local_head {
5448 return format!(
5449 "PR #{} head {} != local HEAD {}: push the latest commits before completing",
5450 pr.number,
5451 short_sha(&pr.head_oid),
5452 short_sha(local_head)
5453 );
5454 }
5455
5456 if !pr.ci_conclusion.is_ok() {
5457 if pr.ci_conclusion == CiConclusion::None {
5458 return format!(
5459 "no CI checks found on PR #{}; declare ci.declared_none: true in settings if intentional",
5460 pr.number
5461 );
5462 }
5463 if pr.ci_conclusion == CiConclusion::Pending {
5468 return format!("CI still running on PR #{}.{}", pr.number, hint("ci"));
5469 }
5470 let check_name = match &pr.ci_conclusion {
5471 CiConclusion::Failure(Some(name)) => name.as_str(),
5472 _ => "CI",
5473 };
5474 return format!("CI red on PR #{}: {} failed", pr.number, check_name);
5475 }
5476
5477 if !pr.reviewed {
5478 if !pr.unaddressed_findings.is_empty() {
5486 let f = &pr.unaddressed_findings[0];
5488 let more = if pr.unaddressed_findings.len() > 1 {
5489 format!(" [+{} more]", pr.unaddressed_findings.len() - 1)
5490 } else {
5491 String::new()
5492 };
5493 let reply_to = profile_by_author(&f.author)
5497 .map(|p| format!(" addressed to {}", p.reply_handle))
5498 .unwrap_or_default();
5499 return format!(
5500 "PR #{}: {} {} at {}:{} unaddressed (reply in-thread{} or wontfix:){}",
5501 pr.number, f.author, f.severity, f.path, f.line, reply_to, more
5502 );
5503 }
5504 if !pr.unattested_reviewers.is_empty() {
5505 let head = short_sha(local_head);
5516 let items: Vec<String> = pr
5517 .unattested_reviewers
5518 .iter()
5519 .map(|r| {
5520 let state = if r.failed_at_head {
5523 " (attested at this head, verdict NOT pass)".to_string()
5524 } else {
5525 match &r.superseded_head {
5526 Some(h) => {
5527 format!(" (passed at {}, superseded by this head)", short_sha(h))
5528 }
5529 None => String::new(),
5530 }
5531 };
5532 if r.name == SAME_MODEL_LOCAL_PEER_SENTINEL {
5533 return format!(
5534 "peer{} -> configure a cross-model peer or routed model",
5535 state
5536 );
5537 }
5538 if r.name == LOCAL_PEER_REVIEWER {
5539 return format!("peer{} -> run `/fno:review peer --attest`", state);
5540 }
5541 match reviewer_invocation(&r.name) {
5542 Some((inv, self_cert)) => {
5543 let mark = if self_cert {
5544 " [self-cert: asserts no review evidence]"
5545 } else {
5546 ""
5547 };
5548 format!("{}{} -> run `{}`{}", r.name, state, inv, mark)
5549 }
5550 None => format!("{}{}", r.name, state),
5551 }
5552 })
5553 .collect();
5554 let corrupt = match pr.malformed_attestations {
5555 0 => String::new(),
5556 n => format!(" ({n} unparseable attestation line(s) ignored)"),
5557 };
5558 return format!(
5559 "PR #{}: reviewers gate unmet - no head-pinned review_attestation at {} for {}{}. \
5560 This is local work to DO, not a wait: no GitHub reviewer posts these, \
5561 so do not arm a watcher.",
5562 pr.number,
5563 head,
5564 items.join("; "),
5565 corrupt
5566 );
5567 }
5568 if !pr.missing_bots.is_empty() {
5569 if let Some(n) = pr
5576 .bot_nudges
5577 .iter()
5578 .find(|n| n.class == NudgeClass::NeedsNudge)
5579 {
5580 return format!(
5581 "PR #{}: {} reviews on mention, not on push, and has not been asked. Run:\n \
5582 gh pr comment {} --body \"{}\"\nthen arm a watcher (nudge {} of {}).{}",
5583 pr.number,
5584 n.login,
5585 pr.number,
5586 n.review_handle,
5587 n.nudges + 1,
5588 n.ceiling,
5589 hint("review")
5590 );
5591 }
5592 if let Some(n) = pr
5593 .bot_nudges
5594 .iter()
5595 .find(|n| n.class == NudgeClass::Unresponsive)
5596 {
5597 return format!(
5598 "PR #{}: {} did not review after {} nudges over {}m. Nothing further \
5599 will arrive on its own. Either post the review by hand, or move this \
5600 login to config.review.optional_apps (honored-if-present, never waited \
5601 on). Not a wait: do not arm a watcher.{}",
5602 pr.number,
5603 n.login,
5604 n.nudges,
5605 n.span_min,
5606 hint("review")
5607 );
5608 }
5609 if let Some(n) = pr
5610 .bot_nudges
5611 .iter()
5612 .find(|n| n.class == NudgeClass::Awaiting)
5613 {
5614 return format!(
5615 "PR #{}: {} nudged {}m ago ({} of {}), awaiting review.{}",
5616 pr.number,
5617 n.login,
5618 n.newest_age_min,
5619 n.nudges,
5620 n.ceiling,
5621 hint("review")
5622 );
5623 }
5624 return format!(
5627 "PR #{}: {} has not reviewed.{}",
5628 pr.number,
5629 pr.missing_bots.join(", "),
5630 hint("review")
5631 );
5632 }
5633 return format!(
5637 "PR #{} not yet reviewed and no reviewer is outstanding; \
5638 re-check config.review (required_bots / reviewers) - nothing here will \
5639 arrive on its own.",
5640 pr.number
5641 );
5642 }
5643
5644 format!("PR #{} done() returned false (unknown reason)", pr.number)
5645}
5646
5647pub fn run_loop_check(args: &[String]) -> i32 {
5652 let (code, json) = decide(args);
5653 println!("{json}");
5654 code
5655}
5656
5657pub fn run_loop_check_capture(args: &[String]) -> (i32, String) {
5660 decide(args)
5661}
5662
5663#[cfg(test)]
5666mod tests {
5667 use super::*;
5668
5669 fn unattested_reviewers(
5672 events_path: &Path,
5673 reviewers: &[String],
5674 head_sha: &str,
5675 ) -> Vec<UnattestedReviewer> {
5676 unattested_reviewers_scan(events_path, reviewers, head_sha).0
5677 }
5678
5679 fn reviewers_all_attested(events_path: &Path, reviewers: &[String], head_sha: &str) -> bool {
5684 unattested_reviewers(events_path, reviewers, head_sha).is_empty()
5685 }
5686
5687 const FP: &str = "FP";
5694 const NOW: &str = "2026-06-05T12:00:00Z";
5695
5696 fn at(ts: &str) -> DateTime<Utc> {
5697 ts.parse().unwrap()
5698 }
5699
5700 fn write_fire_log(path: &Path, fires: &[(String, &str)]) {
5702 let mut out = String::new();
5703 for (ts, fp) in fires {
5704 out.push_str(
5705 &serde_json::json!({
5706 "ts": ts, "type": "loop_check", "source": "hook",
5707 "data": { "session_id": "sess", "fingerprint": fp },
5708 })
5709 .to_string(),
5710 );
5711 out.push('\n');
5712 }
5713 std::fs::write(path, out).unwrap();
5714 }
5715
5716 fn streak_ago(secs_before_now: &[i64], gap: i64) -> (u64, i64) {
5719 let now = at(NOW);
5720 let fires: Vec<(String, &str)> = secs_before_now
5721 .iter()
5722 .map(|s| {
5723 (
5724 (now - chrono::Duration::seconds(*s))
5725 .format("%Y-%m-%dT%H:%M:%SZ")
5726 .to_string(),
5727 FP,
5728 )
5729 })
5730 .collect();
5731 let dir = tempfile::TempDir::new().unwrap();
5732 let p = dir.path().join("events.jsonl");
5733 write_fire_log(&p, &fires);
5734 let (_, streak, _, window) = read_prior_fires(&p, "sess", FP, now, gap);
5735 (streak, window)
5736 }
5737
5738 #[test]
5741 fn debounce_streak_counting_rules() {
5742 #[rustfmt::skip]
5744 let cases: &[(&str, &[i64], i64, u64, i64)] = &[
5745 ("rapid burst collapses to one observation", &[49, 33, 16, 0], 300, 0, 0),
5748 ("fires 6 minutes apart still trip the backstop", &[1440, 1080, 720, 360], 300, 4, 1440),
5750 ("a skip does not advance the cursor", &[330, 60, 30, 10], 300, 1, 330),
5754 ("gap 0 restores fire counting exactly", &[49, 33, 16], 0, 3, 49),
5758 ("a fire stamped after `now` counts, not crashes", &[1200, -600], 300, 2, 1200),
5760 ("the false-NoProgress incident now blocks", &[109, 93, 76, 17], 300, 0, 0),
5763 ];
5764 for (case, fires, gap, want_streak, want_window) in cases {
5765 let (streak, window) = streak_ago(fires, *gap);
5766 assert_eq!(streak, *want_streak, "streak: {case}");
5767 assert_eq!(window, *want_window, "window: {case}");
5768 }
5769 }
5770
5771 #[test]
5774 fn debounce_changed_fingerprint_breaks_streak_at_any_speed() {
5775 let now = at(NOW);
5776 let dir = tempfile::TempDir::new().unwrap();
5777 let p = dir.path().join("events.jsonl");
5778 write_fire_log(
5779 &p,
5780 &[
5781 ("2026-06-05T11:40:00Z".to_string(), FP),
5782 ("2026-06-05T11:50:00Z".to_string(), FP),
5783 ("2026-06-05T11:59:58Z".to_string(), "DIFFERENT"),
5784 ],
5785 );
5786 let (_, streak, _, _) = read_prior_fires(&p, "sess", FP, now, 300);
5787 assert_eq!(streak, 0, "a 2-second-old change still resets the streak");
5788 }
5789
5790 #[test]
5794 fn debounce_untimestamped_fire_is_transparent() {
5795 let dir = tempfile::TempDir::new().unwrap();
5796 let p = dir.path().join("events.jsonl");
5797 let lines = [
5798 r#"{"ts":"2026-06-05T11:40:00Z","type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
5799 r#"{"ts":"not-a-timestamp","type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
5800 r#"{"type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
5801 ];
5802 std::fs::write(&p, lines.join("\n") + "\n").unwrap();
5803
5804 let (_, streak, last_fp, _) = read_prior_fires(&p, "sess", FP, at(NOW), 300);
5805 assert_eq!(
5806 streak, 1,
5807 "unplaceable fires skip; the good one still counts"
5808 );
5809 assert_eq!(
5810 last_fp.as_deref(),
5811 Some(FP),
5812 "carry-forward still reads the newest recorded fp"
5813 );
5814 }
5815
5816 #[test]
5817 fn parse_manifest_minimal() {
5818 let content =
5819 "---\nsession_id: abc\ncreated_at: 2026-06-05T00:00:00Z\nattended: true\n---\n";
5820 let m = parse_manifest(content).unwrap();
5821 assert_eq!(m.session_id.as_deref(), Some("abc"));
5822 assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
5823 assert!(m.attended);
5824 assert!(m.legacy_status.is_none());
5825 }
5826
5827 #[test]
5828 fn scan_manifest_field_reads_claim_fields_after_frontmatter() {
5829 let content = "---\nsession_id: s1\nattended: false\n---\n\
5834 Immutable session manifest.\n\
5835 target_claim_key: \"node:x-ba4b\"\n\
5836 target_claim_holder: \"target-session:s1\"\n\
5837 target_claim_ttl: \"2h\"\n";
5838 let m = parse_manifest(content).unwrap();
5840 assert_eq!(m.session_id.as_deref(), Some("s1"));
5841 assert_eq!(
5843 scan_manifest_field(content, "target_claim_key").as_deref(),
5844 Some("node:x-ba4b")
5845 );
5846 assert_eq!(
5847 scan_manifest_field(content, "target_claim_holder").as_deref(),
5848 Some("target-session:s1")
5849 );
5850 assert_eq!(
5851 scan_manifest_field(content, "target_claim_ttl")
5852 .as_deref()
5853 .and_then(crate::claims::parse_ttl_ms),
5854 Some(7_200_000)
5855 );
5856 assert_eq!(scan_manifest_field(content, "nonexistent_field"), None);
5857 }
5858
5859 #[test]
5860 fn parse_manifest_legacy_complete() {
5861 let content =
5862 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: COMPLETE\n---\n";
5863 let m = parse_manifest(content).unwrap();
5864 assert_eq!(m.legacy_status.as_deref(), Some("COMPLETE"));
5865 }
5866
5867 #[test]
5868 fn parse_manifest_legacy_blocked() {
5869 let content =
5870 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: BLOCKED\n---\n";
5871 let m = parse_manifest(content).unwrap();
5872 assert_eq!(m.legacy_status.as_deref(), Some("BLOCKED"));
5873 }
5874
5875 #[test]
5876 fn parse_manifest_no_ship() {
5877 let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nno_ship: true\n---\n";
5878 let m = parse_manifest(content).unwrap();
5879 assert!(m.no_ship);
5880 assert!(!m.no_external);
5881 }
5882
5883 #[test]
5884 fn parse_manifest_planned() {
5885 let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nplanned: true\n---\n";
5886 let m = parse_manifest(content).unwrap();
5887 assert!(m.planned);
5888 assert!(!m.advisory); }
5890
5891 #[test]
5892 fn parse_manifest_strips_quotes() {
5893 let content = "---\nsession_id: \"s-quoted\"\ncreated_at: '2026-06-05T00:00:00Z'\n---\n";
5895 let m = parse_manifest(content).unwrap();
5896 assert_eq!(m.session_id.as_deref(), Some("s-quoted"));
5897 assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
5898 }
5899
5900 #[test]
5901 fn parse_settings_nested_budget_and_ci() {
5902 let cfg = "[budget.unattended]\ncost_cap_usd = 7.5\n\n[ci]\ndeclared_none = true\n";
5904 let s = parse_settings(cfg);
5905 assert_eq!(s.unattended_cost_cap_usd, Some(Ok(7.5)));
5906 assert!(s.ci_declared_none);
5907 }
5908
5909 #[test]
5910 fn stderr_tail_multibyte_boundary_no_panic() {
5911 let mut payload = String::new();
5913 while payload.len() < 300 {
5914 payload.push('\u{00e9}'); }
5916 let tail = stderr_tail(payload.as_bytes());
5917 assert!(tail.len() <= 200);
5918 assert!(!tail.is_empty());
5919 }
5920
5921 #[test]
5922 fn parse_manifest_attended_default_true() {
5923 let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\n---\n";
5924 let m = parse_manifest(content).unwrap();
5925 assert!(m.attended, "attended should default to true when absent");
5926 }
5927
5928 #[test]
5929 fn parse_manifest_budget_caps() {
5930 let content =
5931 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: 120\nbudget_cost_cap_usd: 5.0\n---\n";
5932 let m = parse_manifest(content).unwrap();
5933 assert_eq!(m.budget_wall_clock_cap_minutes, Some(Ok(120)));
5934 assert_eq!(m.budget_cost_cap_usd, Some(Ok(5.0)));
5935 }
5936
5937 #[test]
5938 fn parse_manifest_no_frontmatter_returns_none() {
5939 let content = "no frontmatter here";
5940 assert!(parse_manifest(content).is_none());
5941 }
5942
5943 #[test]
5944 fn parse_settings_flat_budget_cap() {
5945 let cfg = "budget_cap = 2.5\n";
5946 let s = parse_settings(cfg);
5947 assert_eq!(s.flat_budget_cap, Some(Ok(2.5)));
5948 }
5949
5950 #[test]
5951 fn parse_settings_nested_budget() {
5952 let cfg = "[budget.attended]\nwall_clock_cap_minutes = 90\ncost_cap_usd = 10.0\n\n[budget.unattended]\nwall_clock_cap_minutes = 60\ncost_cap_usd = 5.0\n";
5953 let s = parse_settings(cfg);
5954 assert_eq!(s.attended_wall_cap_minutes, Some(Ok(90)));
5955 assert_eq!(s.attended_cost_cap_usd, Some(Ok(10.0)));
5956 assert_eq!(s.unattended_wall_cap_minutes, Some(Ok(60)));
5957 assert_eq!(s.unattended_cost_cap_usd, Some(Ok(5.0)));
5958 }
5959
5960 #[test]
5961 fn parse_settings_ci_declared_none() {
5962 let cfg = "[ci]\ndeclared_none = true\n";
5963 let s = parse_settings(cfg);
5964 assert!(s.ci_declared_none);
5965 }
5966
5967 #[test]
5968 fn parse_settings_comments_ignored() {
5969 let cfg =
5970 "# top comment\nbudget_cap = 1.0\n# another\n[ci]\n# inner\ndeclared_none = true\n";
5971 let s = parse_settings(cfg);
5972 assert_eq!(s.flat_budget_cap, Some(Ok(1.0)));
5973 assert!(s.ci_declared_none);
5974 }
5975
5976 #[test]
5977 fn detect_intent_promise() {
5978 let tmp = tempfile::tempdir().unwrap();
5979 let path = tmp.path().join("t.jsonl");
5980 let line = serde_json::json!({
5981 "message": {"role": "assistant", "content": "done <promise>COMPLETE</promise>"}
5982 });
5983 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
5984 assert_eq!(detect_intent_full(&path), Intent::Promise);
5985 }
5986
5987 #[test]
5988 fn detect_intent_aborted_beats_promise() {
5989 let tmp = tempfile::tempdir().unwrap();
5990 let path = tmp.path().join("t.jsonl");
5991 let line = serde_json::json!({
5993 "message": {"role": "assistant", "content": "<aborted reason=\"user\">done</aborted>"}
5994 });
5995 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
5996 assert!(matches!(detect_intent_full(&path), Intent::Aborted { .. }));
5997 }
5998
5999 #[test]
6000 fn detect_intent_tool_result_ignored() {
6001 let tmp = tempfile::tempdir().unwrap();
6003 let path = tmp.path().join("t.jsonl");
6004 let user_line = serde_json::json!({
6005 "message": {"role": "user", "content": "<promise>fake</promise>"}
6006 });
6007 std::fs::write(&path, serde_json::to_string(&user_line).unwrap() + "\n").unwrap();
6008 assert_eq!(detect_intent_full(&path), Intent::None);
6009 }
6010
6011 #[test]
6012 fn detect_intent_none_when_no_assistant() {
6013 let tmp = tempfile::tempdir().unwrap();
6014 let path = tmp.path().join("t.jsonl");
6015 let line = serde_json::json!({"message": {"role": "user", "content": "go"}});
6016 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6017 assert_eq!(detect_intent_full(&path), Intent::None);
6018 }
6019
6020 #[test]
6021 fn detect_intent_array_content_blocks() {
6022 let tmp = tempfile::tempdir().unwrap();
6023 let path = tmp.path().join("t.jsonl");
6024 let line = serde_json::json!({
6025 "message": {
6026 "role": "assistant",
6027 "content": [
6028 {"type": "text", "text": "<promise>done</promise>"},
6029 {"type": "tool_use", "name": "Bash"}
6030 ]
6031 }
6032 });
6033 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6034 assert_eq!(detect_intent_full(&path), Intent::Promise);
6035 }
6036
6037 #[test]
6038 fn extract_last_assistant_message_plain_string() {
6039 let payload = r#"{"transcript_path":"/t.jsonl","last_assistant_message":" done <promise>MISSION COMPLETE: x</promise> "}"#;
6040 assert_eq!(
6041 extract_last_assistant_message(payload).as_deref(),
6042 Some("done <promise>MISSION COMPLETE: x</promise>")
6043 );
6044 }
6045
6046 #[test]
6047 fn extract_last_assistant_message_degrades_to_none() {
6048 assert_eq!(
6051 extract_last_assistant_message(r#"{"transcript_path":"/t.jsonl"}"#),
6052 None
6053 );
6054 assert_eq!(extract_last_assistant_message("not json {"), None);
6055 assert_eq!(
6056 extract_last_assistant_message(r#"{"last_assistant_message":{"text":"obj"}}"#),
6057 None
6058 );
6059 assert_eq!(
6060 extract_last_assistant_message(r#"{"last_assistant_message":" "}"#),
6061 None
6062 );
6063 }
6064
6065 #[test]
6066 fn detect_intent_payload_promise_wins_over_stale_transcript() {
6067 let tmp = tempfile::tempdir().unwrap();
6070 let path = tmp.path().join("t.jsonl");
6071 let line = serde_json::json!({
6072 "message": {"role": "assistant", "content": "still working on it"}
6073 });
6074 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6075 let (intent, source) =
6076 detect_intent(Some("<promise>MISSION COMPLETE: done</promise>"), &path);
6077 assert_eq!(intent, Intent::Promise);
6078 assert_eq!(source, "payload");
6079 }
6080
6081 #[test]
6082 fn detect_intent_payload_no_tag_is_authoritative() {
6083 let tmp = tempfile::tempdir().unwrap();
6086 let path = tmp.path().join("t.jsonl");
6087 let line = serde_json::json!({
6088 "message": {"role": "assistant", "content": "<promise>old stale promise</promise>"}
6089 });
6090 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6091 let (intent, source) = detect_intent(Some("moving on to other work"), &path);
6092 assert_eq!(intent, Intent::None);
6093 assert_eq!(source, "payload");
6094 }
6095
6096 #[test]
6097 fn detect_intent_payload_aborted_beats_promise() {
6098 let (intent, source) = detect_intent(
6099 Some("<promise>done</promise> <aborted reason=\"kill\">stop</aborted>"),
6100 Path::new("/nonexistent"),
6101 );
6102 assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
6103 assert_eq!(source, "payload");
6104 }
6105
6106 #[test]
6107 fn watching_intent_parses_all_attrs() {
6108 let (intent, source) = detect_intent(
6109 Some("waiting <watching reason=\"ci\" pr=\"404\" timeout=\"30m\">"),
6110 Path::new("/nonexistent"),
6111 );
6112 assert_eq!(source, "payload");
6113 assert_eq!(
6114 intent,
6115 Intent::Watching {
6116 reason: "ci".into(),
6117 pr: Some("404".into()),
6118 timeout: Some("30m".into()),
6119 }
6120 );
6121 }
6122
6123 #[test]
6124 fn watching_intent_malformed_attrs_default_to_absent() {
6125 let (intent, _) = detect_intent(Some("<watching>"), Path::new("/nonexistent"));
6128 assert_eq!(
6129 intent,
6130 Intent::Watching {
6131 reason: String::new(),
6132 pr: None,
6133 timeout: None,
6134 }
6135 );
6136 }
6137
6138 #[test]
6139 fn watching_intent_aborted_beats_watching() {
6140 let (intent, _) = detect_intent(
6141 Some("<watching reason=\"ci\" pr=\"1\"> <aborted reason=\"kill\">"),
6142 Path::new("/nonexistent"),
6143 );
6144 assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
6145 }
6146
6147 #[test]
6148 fn watching_intent_beats_promise() {
6149 let (intent, _) = detect_intent(
6150 Some("<promise>done</promise> <watching reason=\"review\" pr=\"9\">"),
6151 Path::new("/nonexistent"),
6152 );
6153 assert!(matches!(intent, Intent::Watching { .. }));
6154 }
6155
6156 #[test]
6157 fn watching_intent_newest_transcript_entry_honored() {
6158 let tmp = tempfile::tempdir().unwrap();
6159 let path = tmp.path().join("t.jsonl");
6160 let line = serde_json::json!({
6161 "message": {"role": "assistant", "content": "<watching reason=\"ci\" pr=\"7\">"}
6162 });
6163 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6164 assert!(matches!(detect_intent_full(&path), Intent::Watching { .. }));
6165 }
6166
6167 #[test]
6168 fn watching_intent_stale_transcript_not_honored() {
6169 let tmp = tempfile::tempdir().unwrap();
6172 let path = tmp.path().join("t.jsonl");
6173 let mut content = String::new();
6174 for text in [
6175 "<watching reason=\"ci\" pr=\"3\">", "still going",
6177 "moving on to unrelated work", ] {
6179 let line = serde_json::json!({"message": {"role": "assistant", "content": text}});
6180 content.push_str(&serde_json::to_string(&line).unwrap());
6181 content.push('\n');
6182 }
6183 std::fs::write(&path, content).unwrap();
6184 assert_eq!(detect_intent_full(&path), Intent::None);
6185 }
6186
6187 #[test]
6188 fn watching_intent_stale_watch_does_not_shadow_deeper_promise() {
6189 let tmp = tempfile::tempdir().unwrap();
6192 let path = tmp.path().join("t.jsonl");
6193 let mut content = String::new();
6194 for text in [
6195 "<promise>MISSION COMPLETE: shipped</promise>", "<watching reason=\"ci\" pr=\"3\">", "tag-less newest", ] {
6199 let line = serde_json::json!({"message": {"role": "assistant", "content": text}});
6200 content.push_str(&serde_json::to_string(&line).unwrap());
6201 content.push('\n');
6202 }
6203 std::fs::write(&path, content).unwrap();
6204 assert_eq!(detect_intent_full(&path), Intent::Promise);
6205 }
6206
6207 #[test]
6208 fn detect_intent_absent_payload_falls_back_to_transcript() {
6209 let tmp = tempfile::tempdir().unwrap();
6210 let path = tmp.path().join("t.jsonl");
6211 let line = serde_json::json!({
6212 "message": {"role": "assistant", "content": "<promise>COMPLETE</promise>"}
6213 });
6214 std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
6215 let (intent, source) = detect_intent(None, &path);
6216 assert_eq!(intent, Intent::Promise);
6217 assert_eq!(source, "transcript");
6218 }
6219
6220 #[test]
6221 fn detect_intent_lookback_finds_promise_behind_block_feedback() {
6222 let tmp = tempfile::tempdir().unwrap();
6226 let path = tmp.path().join("t.jsonl");
6227 let mut content = String::new();
6228 for text in [
6229 "<promise>MISSION COMPLETE: shipped</promise>",
6230 "acknowledged the block; checking CI",
6231 "CI is still pending, waiting",
6232 ] {
6233 let line = serde_json::json!({
6234 "message": {"role": "assistant", "content": text}
6235 });
6236 content.push_str(&serde_json::to_string(&line).unwrap());
6237 content.push('\n');
6238 }
6239 std::fs::write(&path, content).unwrap();
6240 assert_eq!(detect_intent_full(&path), Intent::Promise);
6241 }
6242
6243 #[test]
6244 fn detect_intent_lookback_bound_holds() {
6245 let tmp = tempfile::tempdir().unwrap();
6249 let path = tmp.path().join("t.jsonl");
6250 let mut content = String::new();
6251 let line = serde_json::json!({
6252 "message": {"role": "assistant", "content": "<promise>stale</promise>"}
6253 });
6254 content.push_str(&serde_json::to_string(&line).unwrap());
6255 content.push('\n');
6256 for i in 0..INTENT_LOOKBACK_ENTRIES {
6257 let line = serde_json::json!({
6258 "message": {"role": "assistant", "content": format!("pivoted work step {i}")}
6259 });
6260 content.push_str(&serde_json::to_string(&line).unwrap());
6261 content.push('\n');
6262 }
6263 std::fs::write(&path, content).unwrap();
6264 assert_eq!(detect_intent_full(&path), Intent::None);
6265 }
6266
6267 #[test]
6268 fn parse_args_hook_input_stdin_flag() {
6269 let args: Vec<String> = [
6270 "loop-check",
6271 "--state",
6272 "/s.md",
6273 "--transcript",
6274 "/t.jsonl",
6275 "--cwd",
6276 "/w",
6277 "--hook-input-stdin",
6278 ]
6279 .iter()
6280 .map(|s| s.to_string())
6281 .collect();
6282 let parsed = parse_args(&args).unwrap();
6283 assert!(parsed.hook_input_stdin);
6284 assert_eq!(parsed.cwd, PathBuf::from("/w"));
6286 }
6287
6288 #[test]
6289 fn block_reason_pending_ci_is_not_red() {
6290 let pr = PrInfo {
6294 state: PrState::Open,
6295 number: 455,
6296 head_oid: "abc".to_string(),
6297 ci_conclusion: CiConclusion::Pending,
6298 failing_checks: vec![],
6299 ci_has_pending: false,
6300 mergeable: "UNKNOWN".to_string(),
6301 latest_review_ts: "none".to_string(),
6302 reviewed: false,
6303 missing_bots: vec![],
6304 bot_nudges: vec![],
6305 usage_limited: vec![],
6306 unaddressed_findings: vec![],
6307 review_skipped: false,
6308 unattested_reviewers: vec![],
6309 malformed_attestations: 0,
6310 };
6311 let reason = build_block_reason(&pr, "abc", true);
6312 assert!(
6313 reason.contains("still running"),
6314 "pending CI must not read as red; got: {reason}"
6315 );
6316 assert!(!reason.contains("failed"), "got: {reason}");
6317 }
6318
6319 #[test]
6320 fn unwatched_async_nudge_ci_pending_teaches_arm_and_tag() {
6321 let pr = PrInfo {
6325 ci_conclusion: CiConclusion::Pending,
6326 ci_has_pending: true,
6327 ..watch_pr()
6328 };
6329 let reason = build_block_reason(&pr, "abc", true);
6330 assert!(reason.contains("<watching"), "got: {reason}");
6331 assert!(reason.contains("timeout"), "got: {reason}");
6332 assert!(reason.contains("gh pr checks"), "got: {reason}");
6333 assert!(!reason.contains("wait silently"), "got: {reason}");
6334 }
6335
6336 #[test]
6337 fn no_hint_prescribes_the_timeout_binary() {
6338 let needle = ["timeout", " "].concat();
6342 for tail in include_str!("loopcheck.rs").split(&needle).skip(1) {
6343 assert!(
6344 !tail.trim_start().starts_with(|c: char| c.is_ascii_digit()),
6345 "bare timeout invocation: ...{}",
6346 tail.chars().take(60).collect::<String>()
6347 );
6348 }
6349 }
6350
6351 #[test]
6352 fn unwatched_async_nudge_missing_review_teaches_arm_and_tag() {
6353 let pr = PrInfo {
6354 ci_conclusion: CiConclusion::Success,
6355 ci_has_pending: false,
6356 reviewed: false,
6357 missing_bots: vec!["chatgpt-codex-connector".into()],
6358 bot_nudges: vec![],
6359 ..watch_pr()
6360 };
6361 let reason = build_block_reason(&pr, "abc", true);
6362 assert!(reason.contains("chatgpt-codex-connector"), "got: {reason}");
6363 assert!(reason.contains("<watching"), "got: {reason}");
6364 }
6365
6366 fn watch_pr() -> PrInfo {
6369 PrInfo {
6370 state: PrState::Open,
6371 number: 404,
6372 head_oid: "abc".to_string(),
6373 ci_conclusion: CiConclusion::Pending,
6374 failing_checks: vec![],
6375 ci_has_pending: true,
6376 mergeable: "UNKNOWN".to_string(),
6377 latest_review_ts: "none".to_string(),
6378 reviewed: false,
6379 missing_bots: vec![],
6380 bot_nudges: vec![],
6381 usage_limited: vec![],
6382 unaddressed_findings: vec![],
6383 review_skipped: false,
6384 unattested_reviewers: vec![],
6385 malformed_attestations: 0,
6386 }
6387 }
6388
6389 #[test]
6390 fn watch_idle_classifies_pending_ci() {
6391 assert_eq!(async_wait_class(&watch_pr(), "abc", true), Some("ci"));
6392 }
6393
6394 #[test]
6395 fn codex_watch_harness_gate_is_claude_only() {
6396 assert!(harness_can_idle(Some("claude"), false));
6398 assert!(!harness_can_idle(Some("claude"), true));
6400 assert!(!harness_can_idle(Some("codex"), false));
6403 assert!(!harness_can_idle(Some("gemini"), false));
6404 assert!(!harness_can_idle(None, false));
6406 }
6407
6408 #[test]
6409 fn watch_idle_classifies_awaiting_review() {
6410 let pr = PrInfo {
6412 ci_conclusion: CiConclusion::Success,
6413 ci_has_pending: false,
6414 reviewed: false,
6415 review_skipped: false,
6416 missing_bots: vec!["chatgpt-codex-connector".into()],
6417 bot_nudges: vec![],
6418 ..watch_pr()
6419 };
6420 assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6421 }
6422
6423 #[test]
6424 fn watch_idle_rejects_ci_pending_with_a_failure() {
6425 let pr = PrInfo {
6428 ci_conclusion: CiConclusion::Failure(Some("unit".into())),
6429 ci_has_pending: true,
6430 ..watch_pr()
6431 };
6432 assert_eq!(async_wait_class(&pr, "abc", true), None);
6433 }
6434
6435 #[test]
6436 fn watch_idle_rejects_local_attestation_review_gate() {
6437 let pr = PrInfo {
6441 ci_conclusion: CiConclusion::Success,
6442 ci_has_pending: false,
6443 reviewed: false,
6444 review_skipped: false,
6445 missing_bots: vec![],
6446 bot_nudges: vec![],
6447 ..watch_pr()
6448 };
6449 assert_eq!(async_wait_class(&pr, "abc", true), None);
6450 }
6451
6452 fn bn(login: &str, class: NudgeClass, nudges: usize, newest: i64, span: i64) -> BotNudge {
6455 BotNudge {
6456 login: login.into(),
6457 class,
6458 review_handle: "@codex review".into(),
6459 ceiling: 3,
6460 nudges,
6461 newest_age_min: newest,
6462 span_min: span,
6463 }
6464 }
6465 fn bot_review_pr(login: &str, nudges: Vec<BotNudge>) -> PrInfo {
6466 PrInfo {
6467 number: 618,
6468 ci_conclusion: CiConclusion::Success,
6469 ci_has_pending: false,
6470 reviewed: false,
6471 review_skipped: false,
6472 missing_bots: vec![login.into()],
6473 bot_nudges: nudges,
6474 ..watch_pr()
6475 }
6476 }
6477
6478 #[test]
6479 fn nudge_needs_nudge_blocks_and_names_the_command() {
6480 let pr = bot_review_pr(
6482 "chatgpt-codex-connector",
6483 vec![bn(
6484 "chatgpt-codex-connector",
6485 NudgeClass::NeedsNudge,
6486 0,
6487 0,
6488 0,
6489 )],
6490 );
6491 assert_eq!(async_wait_class(&pr, "abc", true), None);
6492 let reason = build_block_reason(&pr, "abc", true);
6493 assert!(
6494 reason.contains("gh pr comment 618 --body \"@codex review\""),
6495 "{reason}"
6496 );
6497 assert!(
6498 !reason.contains("harness-tracked watcher"),
6499 "no arm hint: {reason}"
6500 );
6501 }
6502
6503 #[test]
6504 fn nudge_awaiting_idles_with_the_arm_hint() {
6505 let pr = bot_review_pr(
6508 "chatgpt-codex-connector",
6509 vec![bn("chatgpt-codex-connector", NudgeClass::Awaiting, 1, 3, 3)],
6510 );
6511 assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6512 let reason = build_block_reason(&pr, "abc", true);
6513 assert!(reason.contains("nudged"), "{reason}");
6514 assert!(reason.contains("awaiting"), "{reason}");
6515 assert!(
6516 reason.contains("harness-tracked watcher"),
6517 "arm hint present: {reason}"
6518 );
6519 }
6520
6521 #[test]
6522 fn nudge_unresponsive_blocks_and_names_optional_apps() {
6523 let pr = bot_review_pr(
6525 "chatgpt-codex-connector",
6526 vec![bn(
6527 "chatgpt-codex-connector",
6528 NudgeClass::Unresponsive,
6529 3,
6530 20,
6531 47,
6532 )],
6533 );
6534 assert_eq!(async_wait_class(&pr, "abc", true), None);
6535 let reason = build_block_reason(&pr, "abc", true);
6536 assert!(
6537 reason.contains("did not review after 3 nudges over 47m"),
6538 "{reason}"
6539 );
6540 assert!(reason.contains("config.review.optional_apps"), "{reason}");
6541 assert!(reason.contains("do not arm a watcher"), "{reason}");
6542 assert!(
6543 !reason.contains("harness-tracked watcher"),
6544 "no arm hint: {reason}"
6545 );
6546 }
6547
6548 #[test]
6549 fn nudge_not_nudgeable_keeps_todays_behavior() {
6550 let pr = bot_review_pr(
6553 "gemini-code-assist",
6554 vec![bn("gemini-code-assist", NudgeClass::NotNudgeable, 0, 0, 0)],
6555 );
6556 assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6557 let reason = build_block_reason(&pr, "abc", true);
6558 assert!(
6559 reason.contains("gemini-code-assist has not reviewed"),
6560 "{reason}"
6561 );
6562 assert!(
6563 reason.contains("harness-tracked watcher"),
6564 "arm hint present: {reason}"
6565 );
6566 }
6567
6568 #[test]
6569 fn nudge_empty_classification_is_status_quo() {
6570 let pr = bot_review_pr("chatgpt-codex-connector", vec![]);
6573 assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
6574 let reason = build_block_reason(&pr, "abc", true);
6575 assert!(reason.contains("has not reviewed"), "{reason}");
6576 }
6577
6578 #[test]
6579 fn finding_block_reason_names_the_reply_handle() {
6580 let pr = PrInfo {
6583 ci_conclusion: CiConclusion::Success,
6584 ci_has_pending: false,
6585 reviewed: false,
6586 unaddressed_findings: vec![Finding {
6587 id: 1,
6588 author: "chatgpt-codex-connector".into(),
6589 path: "a.rs".into(),
6590 line: 10,
6591 created_at: "2026-07-06T01:00:00Z".into(),
6592 severity: "P1",
6593 }],
6594 ..watch_pr()
6595 };
6596 let reason = build_block_reason(&pr, "abc", true);
6597 assert!(reason.contains("@chatgpt-codex-connector"), "{reason}");
6598 }
6599
6600 #[test]
6601 fn nudge_post_is_suppressed_by_the_escape_hatch() {
6602 std::env::set_var("FNO_LOOPCHECK_NO_COMMENT", "1");
6606 let posted = post_nudge_comment(
6607 "/nonexistent/gh",
6608 std::path::Path::new("/tmp"),
6609 618,
6610 "@codex review",
6611 );
6612 std::env::remove_var("FNO_LOOPCHECK_NO_COMMENT");
6613 assert!(!posted);
6614 }
6615
6616 #[test]
6617 fn unresponsive_bot_drives_the_giveup_message() {
6618 let pr = bot_review_pr(
6621 "chatgpt-codex-connector",
6622 vec![bn(
6623 "chatgpt-codex-connector",
6624 NudgeClass::Unresponsive,
6625 3,
6626 20,
6627 47,
6628 )],
6629 );
6630 let n = unresponsive_bot(&pr).expect("an unresponsive bot");
6631 let msg = nudge_giveup_message(n);
6632 assert!(msg.contains("chatgpt-codex-connector"), "{msg}");
6633 assert!(msg.contains("3 nudges over 47m"), "{msg}");
6634 assert!(msg.contains("config.review.optional_apps"), "{msg}");
6635 }
6636
6637 #[test]
6638 fn no_giveup_for_an_awaiting_bot() {
6639 let pr = bot_review_pr(
6640 "chatgpt-codex-connector",
6641 vec![bn("chatgpt-codex-connector", NudgeClass::Awaiting, 1, 3, 3)],
6642 );
6643 assert!(unresponsive_bot(&pr).is_none());
6644 }
6645
6646 fn reviewers_gate_pr() -> PrInfo {
6650 PrInfo {
6651 ci_conclusion: CiConclusion::Success,
6652 ci_has_pending: false,
6653 reviewed: false,
6654 review_skipped: false,
6655 missing_bots: vec![],
6656 bot_nudges: vec![],
6657 unaddressed_findings: vec![],
6658 unattested_reviewers: vec![UnattestedReviewer {
6659 name: "sigma".to_string(),
6660 superseded_head: None,
6661 failed_at_head: false,
6662 }],
6663 ..watch_pr()
6664 }
6665 }
6666
6667 #[test]
6668 fn block_reason_names_the_reviewers_gate_not_a_bot() {
6669 let reason = build_block_reason(&reviewers_gate_pr(), "abc", true);
6672 assert!(reason.contains("reviewers gate unmet"), "got: {reason}");
6673 assert!(reason.contains("sigma"), "got: {reason}");
6674 assert!(reason.contains("/fno:review sigma"), "got: {reason}");
6675 assert!(!reason.contains("bot reviewer"), "got: {reason}");
6676 }
6677
6678 #[test]
6679 fn block_reason_names_the_local_peer_invocation() {
6680 let mut pr = reviewers_gate_pr();
6681 pr.unattested_reviewers[0].name = LOCAL_PEER_REVIEWER.to_string();
6682 let reason = build_block_reason(&pr, "abc", true);
6683 assert!(
6684 reason.contains("/fno:review peer --attest"),
6685 "got: {reason}"
6686 );
6687 assert!(
6688 !reason.contains("wait on a GitHub reviewer"),
6689 "got: {reason}"
6690 );
6691 }
6692
6693 #[test]
6694 fn block_reason_explains_same_model_local_peer_refusal() {
6695 let mut pr = reviewers_gate_pr();
6696 pr.unattested_reviewers[0].name = SAME_MODEL_LOCAL_PEER_SENTINEL.to_string();
6697 let reason = build_block_reason(&pr, "abc", true);
6698 assert!(
6699 reason.contains("configure a cross-model peer"),
6700 "got: {reason}"
6701 );
6702 assert!(
6703 !reason.contains(SAME_MODEL_LOCAL_PEER_SENTINEL),
6704 "got: {reason}"
6705 );
6706 }
6707
6708 #[test]
6709 fn block_reason_reviewers_gate_emits_no_idle_ritual() {
6710 let pr = reviewers_gate_pr();
6714 assert_eq!(async_wait_class(&pr, "abc", true), None);
6715 let reason = build_block_reason(&pr, "abc", true);
6716 assert!(!reason.contains("<watching"), "got: {reason}");
6717 assert!(
6718 !reason.contains("Arm a harness-tracked watcher"),
6719 "got: {reason}"
6720 );
6721 assert!(!reason.contains("gh pr checks"), "got: {reason}");
6722 }
6723
6724 #[test]
6725 fn block_reason_names_a_superseded_attestation_head() {
6726 let pr = PrInfo {
6729 unattested_reviewers: vec![UnattestedReviewer {
6730 name: "sigma".to_string(),
6731 superseded_head: Some("0123456789abcdef".to_string()),
6732 failed_at_head: false,
6733 }],
6734 ..reviewers_gate_pr()
6735 };
6736 let reason = build_block_reason(&pr, "abc", true);
6737 assert!(reason.contains("01234567"), "got: {reason}");
6738 assert!(reason.contains("superseded"), "got: {reason}");
6739 }
6740
6741 #[test]
6742 fn block_reason_generic_review_fallback_has_no_idle_ritual() {
6743 let pr = PrInfo {
6746 unattested_reviewers: vec![],
6747 ..reviewers_gate_pr()
6748 };
6749 let reason = build_block_reason(&pr, "abc", true);
6750 assert!(!reason.contains("<watching"), "got: {reason}");
6751 assert!(!reason.contains("bot reviewer"), "got: {reason}");
6752 }
6753
6754 #[test]
6755 fn block_reason_missing_bot_still_teaches_the_ritual() {
6756 let pr = PrInfo {
6759 missing_bots: vec!["chatgpt-codex-connector".into()],
6760 bot_nudges: vec![],
6761 unattested_reviewers: vec![],
6762 ..reviewers_gate_pr()
6763 };
6764 let reason = build_block_reason(&pr, "abc", true);
6765 assert!(reason.contains("chatgpt-codex-connector"), "got: {reason}");
6766 assert!(reason.contains("<watching"), "got: {reason}");
6767 }
6768
6769 #[test]
6770 fn block_reason_local_work_outranks_a_bot_wait() {
6771 let pr = PrInfo {
6777 missing_bots: vec!["chatgpt-codex-connector".into()],
6778 bot_nudges: vec![],
6779 ..reviewers_gate_pr()
6780 };
6781 let reason = build_block_reason(&pr, "abc", true);
6782 assert!(reason.contains("reviewers gate unmet"), "got: {reason}");
6783 assert!(!reason.contains("<watching"), "got: {reason}");
6784 let after = PrInfo {
6787 unattested_reviewers: vec![],
6788 ..pr
6789 };
6790 assert!(build_block_reason(&after, "abc", true).contains("<watching"));
6791 }
6792
6793 #[test]
6794 fn reviewers_gate_stays_fail_closed() {
6795 let tmp = tempfile::tempdir().unwrap();
6799 let missing = tmp.path().join("absent.jsonl");
6800 let sigma = vec!["sigma".to_string()];
6801 assert!(!unattested_reviewers(&missing, &sigma, "h").is_empty());
6802
6803 let stale = tmp.path().join("stale.jsonl");
6804 std::fs::write(
6805 &stale,
6806 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6807 )
6808 .unwrap();
6809 let out = unattested_reviewers(&stale, &sigma, "NEW");
6810 assert_eq!(out.len(), 1);
6811 assert_eq!(out[0].superseded_head.as_deref(), Some("OLD"));
6812 assert!(!out[0].failed_at_head);
6813
6814 let failed = tmp.path().join("fail.jsonl");
6815 std::fs::write(
6816 &failed,
6817 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
6818 )
6819 .unwrap();
6820 let out = unattested_reviewers(&failed, &sigma, "h");
6821 assert_eq!(out.len(), 1);
6822 assert_eq!(out[0].superseded_head, None);
6824 assert!(
6829 out[0].failed_at_head,
6830 "a fail at HEAD must be reported as such"
6831 );
6832 }
6833
6834 #[test]
6835 fn unpinned_attestation_never_counts_as_evidence() {
6836 let tmp = tempfile::tempdir().unwrap();
6840 let p = tmp.path().join("e.jsonl");
6841 std::fs::write(
6842 &p,
6843 r#"{"type":"review_attestation","data":{"reviewer":"sigma","verdict":"pass"}}"#,
6844 )
6845 .unwrap();
6846 let out = unattested_reviewers(&p, &["sigma".to_string()], "");
6847 assert_eq!(out.len(), 1, "unpinned evidence must not satisfy the gate");
6848 assert_eq!(out[0].superseded_head, None);
6849 }
6850
6851 #[test]
6852 fn a_failed_old_head_is_not_reported_as_superseded() {
6853 let tmp = tempfile::tempdir().unwrap();
6856 let p = tmp.path().join("e.jsonl");
6857 std::fs::write(
6858 &p,
6859 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
6860 )
6861 .unwrap();
6862 let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
6863 assert_eq!(out.len(), 1);
6864 assert_eq!(out[0].superseded_head, None);
6865 }
6866
6867 #[test]
6868 fn a_corrupt_attestation_line_is_counted_and_named() {
6869 let tmp = tempfile::tempdir().unwrap();
6875 let p = tmp.path().join("e.jsonl");
6876 std::fs::write(
6877 &p,
6878 concat!(
6879 r#"{"type":"review_attestation","data":{"reviewer":"sigma","hea"#,
6880 "\n",
6881 r#"{"type":"loop_check","data":{}}"#,
6882 ),
6883 )
6884 .unwrap();
6885 let (out, malformed) = unattested_reviewers_scan(&p, &["sigma".to_string()], "h");
6886 assert_eq!(out.len(), 1, "a corrupt line never satisfies the gate");
6887 assert_eq!(malformed, 1, "and it is counted, not silently dropped");
6888
6889 let pr = PrInfo {
6890 malformed_attestations: malformed,
6891 ..reviewers_gate_pr()
6892 };
6893 let reason = build_block_reason(&pr, "abc", true);
6894 assert!(
6895 reason.contains("unparseable attestation line"),
6896 "got: {reason}"
6897 );
6898
6899 std::fs::write(&p, r#"{"type":"loop_check","data":{}}"#).unwrap();
6901 assert_eq!(
6902 unattested_reviewers_scan(&p, &["sigma".to_string()], "h").1,
6903 0
6904 );
6905 assert!(!build_block_reason(&reviewers_gate_pr(), "abc", true)
6906 .contains("unparseable attestation line"));
6907 }
6908
6909 #[test]
6910 fn a_revoked_pass_falls_back_to_an_older_passing_head() {
6911 let tmp = tempfile::tempdir().unwrap();
6917 let p = tmp.path().join("e.jsonl");
6918 let line = |head: &str, verdict: &str| {
6919 format!(
6920 r#"{{"type":"review_attestation","data":{{"reviewer":"sigma","head_sha":"{head}","verdict":"{verdict}"}}}}"#
6921 )
6922 };
6923 std::fs::write(
6924 &p,
6925 [
6926 line("AAA", "pass"),
6927 line("BBB", "pass"),
6928 line("BBB", "fail"),
6929 ]
6930 .join("\n"),
6931 )
6932 .unwrap();
6933 let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
6934 assert_eq!(out.len(), 1);
6935 assert_eq!(
6936 out[0].superseded_head.as_deref(),
6937 Some("AAA"),
6938 "a still-valid older pass must survive a newer head's retraction"
6939 );
6940
6941 std::fs::write(&p, [line("AAA", "pass"), line("BBB", "pass")].join("\n")).unwrap();
6943 let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
6944 assert_eq!(out[0].superseded_head.as_deref(), Some("BBB"));
6945
6946 std::fs::write(
6948 &p,
6949 [
6950 line("AAA", "pass"),
6951 line("BBB", "pass"),
6952 line("BBB", "fail"),
6953 line("AAA", "fail"),
6954 ]
6955 .join("\n"),
6956 )
6957 .unwrap();
6958 let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
6959 assert_eq!(out[0].superseded_head, None);
6960 }
6961
6962 #[test]
6963 fn a_later_fail_revokes_the_superseded_pass_for_that_head() {
6964 let tmp = tempfile::tempdir().unwrap();
6969 let p = tmp.path().join("e.jsonl");
6970 std::fs::write(
6971 &p,
6972 concat!(
6973 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6974 "\n",
6975 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
6976 ),
6977 )
6978 .unwrap();
6979 let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
6980 assert_eq!(out.len(), 1);
6981 assert_eq!(
6982 out[0].superseded_head, None,
6983 "a retracted pass is not evidence"
6984 );
6985
6986 std::fs::write(
6989 &p,
6990 concat!(
6991 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6992 "\n",
6993 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
6994 "\n",
6995 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
6996 ),
6997 )
6998 .unwrap();
6999 let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
7000 assert_eq!(out[0].superseded_head.as_deref(), Some("OLD"));
7001 }
7002
7003 #[test]
7004 fn short_sha_never_panics_on_multibyte() {
7005 assert_eq!(short_sha("0123456789ab"), "01234567");
7008 assert_eq!(short_sha("abc"), "abc");
7009 assert_eq!(short_sha(""), "");
7010 assert_eq!(short_sha("1234567\u{e9}xyz"), "1234567\u{e9}");
7011 let pr = PrInfo {
7012 unattested_reviewers: vec![UnattestedReviewer {
7013 name: "sigma".to_string(),
7014 superseded_head: Some("1234567\u{e9}abc".to_string()),
7015 failed_at_head: false,
7016 }],
7017 ..reviewers_gate_pr()
7018 };
7019 build_block_reason(&pr, "1234567\u{e9}abc", true);
7020 }
7021
7022 #[test]
7023 fn watcher_hint_never_contradicts_the_idle_classifier() {
7024 let bot_only = PrInfo {
7029 missing_bots: vec!["chatgpt-codex-connector".into()],
7030 bot_nudges: vec![],
7031 unattested_reviewers: vec![],
7032 ..reviewers_gate_pr()
7033 };
7034 for (label, pr, open_empty) in [
7035 (
7043 "bot + unaddressed finding (renders as the finding)",
7044 PrInfo {
7045 missing_bots: vec!["chatgpt-codex-connector".into()],
7046 bot_nudges: vec![],
7047 unattested_reviewers: vec![],
7048 unaddressed_findings: vec![Finding {
7049 id: 1,
7050 author: "codex".into(),
7051 path: "a.rs".into(),
7052 line: 1,
7053 created_at: "2026-07-27T00:00:00Z".into(),
7054 severity: "P1",
7055 }],
7056 ..reviewers_gate_pr()
7057 },
7058 true,
7059 ),
7060 (
7061 "bot + open operator finding",
7063 PrInfo {
7064 missing_bots: vec!["chatgpt-codex-connector".into()],
7065 bot_nudges: vec![],
7066 unattested_reviewers: vec![],
7067 ..reviewers_gate_pr()
7068 },
7069 false,
7070 ),
7071 ] {
7072 let reason = build_block_reason(&pr, "abc", open_empty);
7073 assert_eq!(async_wait_class(&pr, "abc", open_empty), None, "{label}");
7074 assert!(!reason.contains("<watching"), "{label}: {reason}");
7075 }
7076 assert_eq!(async_wait_class(&bot_only, "abc", true), Some("review"));
7078 assert!(build_block_reason(&bot_only, "abc", true).contains("<watching"));
7079 }
7080
7081 #[test]
7082 fn an_unaddressed_finding_is_named_before_the_reviewers_gate() {
7083 let pr = PrInfo {
7087 unaddressed_findings: vec![Finding {
7088 id: 1,
7089 author: "codex".into(),
7090 path: "a.rs".into(),
7091 line: 7,
7092 created_at: "2026-07-27T00:00:00Z".into(),
7093 severity: "P1",
7094 }],
7095 ..reviewers_gate_pr()
7096 };
7097 let reason = build_block_reason(&pr, "abc", true);
7098 assert!(reason.contains("unaddressed"), "got: {reason}");
7099 assert!(!reason.contains("reviewers gate unmet"), "got: {reason}");
7100 let after = PrInfo {
7102 unaddressed_findings: vec![],
7103 ..pr
7104 };
7105 assert!(build_block_reason(&after, "abc", true).contains("reviewers gate unmet"));
7106 }
7107
7108 #[test]
7109 fn a_failed_attestation_at_this_head_is_not_reported_as_absent() {
7110 let pr = PrInfo {
7113 unattested_reviewers: vec![UnattestedReviewer {
7114 name: "sigma".to_string(),
7115 superseded_head: None,
7116 failed_at_head: true,
7117 }],
7118 ..reviewers_gate_pr()
7119 };
7120 let reason = build_block_reason(&pr, "abc", true);
7121 assert!(reason.contains("verdict NOT pass"), "got: {reason}");
7122 }
7123
7124 #[test]
7125 fn the_stop_gate_marks_declare_as_a_self_cert() {
7126 let pr = PrInfo {
7129 unattested_reviewers: vec![UnattestedReviewer {
7130 name: "declare".to_string(),
7131 superseded_head: None,
7132 failed_at_head: false,
7133 }],
7134 ..reviewers_gate_pr()
7135 };
7136 let reason = build_block_reason(&pr, "abc", true);
7137 assert!(reason.contains("self-cert"), "got: {reason}");
7138 assert!(
7139 reason.contains("asserts no review evidence"),
7140 "got: {reason}"
7141 );
7142 assert!(!build_block_reason(&reviewers_gate_pr(), "abc", true).contains("self-cert"));
7144 }
7145
7146 #[test]
7147 fn an_empty_head_sha_never_becomes_a_superseded_head() {
7148 let tmp = tempfile::tempdir().unwrap();
7151 let p = tmp.path().join("e.jsonl");
7152 std::fs::write(
7153 &p,
7154 r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"","verdict":"pass"}}"#,
7155 )
7156 .unwrap();
7157 let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
7158 assert_eq!(out.len(), 1);
7159 assert_eq!(out[0].superseded_head, None);
7160 }
7161
7162 #[test]
7163 fn an_outstanding_local_reviewer_is_never_an_idlable_wait() {
7164 let pr = PrInfo {
7168 missing_bots: vec!["chatgpt-codex-connector".into()],
7169 bot_nudges: vec![],
7170 ..reviewers_gate_pr()
7171 };
7172 assert_eq!(async_wait_class(&pr, "abc", true), None);
7173 }
7174
7175 #[test]
7176 fn reviewer_invocations_cover_the_descriptor_table() {
7177 for (name, inv, self_cert) in REVIEWER_INVOCATIONS {
7180 assert!(!inv.is_empty(), "{name} has no invocation");
7181 assert_eq!(reviewer_invocation(name), Some((*inv, *self_cert)));
7182 }
7183 assert_eq!(reviewer_invocation("teleport"), None);
7184 assert_eq!(reviewer_invocation("declare").map(|(_, sc)| sc), Some(true));
7186 assert_eq!(reviewer_invocation("sigma").map(|(_, sc)| sc), Some(false));
7187 }
7188
7189 #[test]
7190 fn unwatched_async_nudge_review_uses_review_aware_watcher() {
7191 let hint = arm_watch_hint(404, "review");
7194 assert!(hint.contains("--json reviews"), "got: {hint}");
7195 assert!(!hint.contains("gh pr checks"), "got: {hint}");
7196 let ci_hint = arm_watch_hint(404, "ci");
7198 assert!(ci_hint.contains("gh pr checks"), "got: {ci_hint}");
7199 }
7200
7201 #[test]
7202 fn watch_idle_rejects_head_mismatch() {
7203 assert_eq!(async_wait_class(&watch_pr(), "def", true), None);
7205 }
7206
7207 #[test]
7208 fn watch_idle_rejects_ci_red() {
7209 let pr = PrInfo {
7211 ci_conclusion: CiConclusion::Failure(Some("unit".into())),
7212 ci_has_pending: false,
7213 ..watch_pr()
7214 };
7215 assert_eq!(async_wait_class(&pr, "abc", true), None);
7216 }
7217
7218 #[test]
7219 fn watch_idle_rejects_unaddressed_finding() {
7220 let pr = PrInfo {
7222 unaddressed_findings: vec![Finding {
7223 id: 1,
7224 author: "codex".into(),
7225 path: "a.rs".into(),
7226 line: 1,
7227 created_at: "none".into(),
7228 severity: "P1",
7229 }],
7230 ..watch_pr()
7231 };
7232 assert_eq!(async_wait_class(&pr, "abc", true), None);
7233 }
7234
7235 #[test]
7236 fn watch_idle_rejects_open_operator_finding() {
7237 assert_eq!(async_wait_class(&watch_pr(), "abc", false), None);
7239 }
7240
7241 #[test]
7242 fn watch_idle_rejects_non_open_pr() {
7243 let pr = PrInfo {
7245 state: PrState::Merged,
7246 ..watch_pr()
7247 };
7248 assert_eq!(async_wait_class(&pr, "abc", true), None);
7249 }
7250
7251 #[test]
7252 fn watch_idle_window_defaults_clamps_and_slacks() {
7253 assert_eq!(watch_window_ms(None), 30 * 60_000 + WATCH_SLACK_MS);
7255 assert_eq!(watch_window_ms(Some("30m")), 30 * 60_000 + WATCH_SLACK_MS);
7257 assert_eq!(watch_window_ms(Some("1m")), 5 * 60_000 + WATCH_SLACK_MS);
7259 assert_eq!(watch_window_ms(Some("5h")), 2 * 3_600_000 + WATCH_SLACK_MS);
7261 assert_eq!(watch_window_ms(Some("soon")), 30 * 60_000 + WATCH_SLACK_MS);
7263 }
7264
7265 #[test]
7266 fn fingerprint_format() {
7267 let fp = make_fingerprint("sha123", "OPEN", "SUCCESS", "2026-06-05T01:00:00Z");
7268 assert_eq!(fp, "sha123|OPEN|SUCCESS|2026-06-05T01:00:00Z");
7269 }
7270
7271 #[test]
7272 fn ci_conclusion_failure_extracts_name() {
7273 let checks = serde_json::json!([
7274 {"name": "unit-tests", "state": "FAILURE", "bucket": "fail"}
7275 ]);
7276 let result = compute_ci_conclusion(&checks).unwrap();
7277 assert_eq!(
7278 result,
7279 CiConclusion::Failure(Some("unit-tests".to_string()))
7280 );
7281 let rendered = result.render();
7282 assert!(rendered.starts_with("FAILURE:"), "got: {rendered}");
7283 assert!(rendered.contains("unit-tests"), "got: {rendered}");
7284 }
7285
7286 #[test]
7288 fn ci_conclusion_cancel_is_failure() {
7289 let checks = serde_json::json!([
7290 {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
7291 {"name": "deploy", "state": "CANCELLED", "bucket": "cancel"}
7292 ]);
7293 assert_eq!(
7294 compute_ci_conclusion(&checks).unwrap(),
7295 CiConclusion::Failure(Some("deploy".to_string()))
7296 );
7297 }
7298
7299 #[test]
7301 fn ci_conclusion_bucket_vocabulary() {
7302 let green = serde_json::json!([
7303 {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
7304 {"name": "publish", "state": "SKIPPED", "bucket": "skipping"}
7305 ]);
7306 assert_eq!(
7307 compute_ci_conclusion(&green).unwrap(),
7308 CiConclusion::Success
7309 );
7310
7311 let pending = serde_json::json!([
7312 {"name": "ci", "state": "SUCCESS", "bucket": "pass"},
7313 {"name": "smoke", "state": "IN_PROGRESS", "bucket": "pending"}
7314 ]);
7315 assert_eq!(
7316 compute_ci_conclusion(&pending).unwrap(),
7317 CiConclusion::Pending
7318 );
7319 }
7320
7321 #[test]
7323 fn ci_conclusion_unknown_bucket_fails_closed() {
7324 let unknown = serde_json::json!([
7325 {"name": "ci", "state": "SUCCESS", "bucket": "mystery"}
7326 ]);
7327 assert_eq!(
7328 compute_ci_conclusion(&unknown).unwrap(),
7329 CiConclusion::Pending
7330 );
7331
7332 let missing = serde_json::json!([{"name": "ci", "state": "SUCCESS"}]);
7333 assert_eq!(
7334 compute_ci_conclusion(&missing).unwrap(),
7335 CiConclusion::Pending
7336 );
7337 }
7338
7339 #[test]
7340 fn ci_conclusion_empty_returns_none() {
7341 let checks = serde_json::json!([]);
7342 let result = compute_ci_conclusion(&checks).unwrap();
7343 assert_eq!(result, CiConclusion::None);
7344 assert_eq!(result.render(), "none");
7345 }
7346
7347 #[test]
7348 fn ci_conclusion_all_success() {
7349 let checks = serde_json::json!([
7350 {"name": "ci", "state": "SUCCESS", "bucket": "pass"}
7351 ]);
7352 let result = compute_ci_conclusion(&checks).unwrap();
7353 assert_eq!(result, CiConclusion::Success);
7354 assert_eq!(result.render(), "SUCCESS");
7355 }
7356
7357 #[test]
7360 fn failing_check_names_collects_fail_and_cancel_only() {
7361 let checks = serde_json::json!([
7362 {"name": "smoke", "bucket": "fail"},
7363 {"name": "loc-ratchet", "bucket": "pass"},
7364 {"name": "prompt-drift", "bucket": "cancel"},
7365 {"name": "self-test", "bucket": "pending"},
7366 {"name": "doc-colo", "bucket": "skipping"},
7367 ]);
7368 let mut got = failing_check_names(&checks);
7369 got.sort();
7370 assert_eq!(got, vec!["prompt-drift".to_string(), "smoke".to_string()]);
7371 }
7372
7373 #[test]
7374 fn failing_check_names_empty_when_all_green() {
7375 let checks = serde_json::json!([{"name": "smoke", "bucket": "pass"}]);
7376 assert!(failing_check_names(&checks).is_empty());
7377 assert!(failing_check_names(&serde_json::json!({})).is_empty());
7379 }
7380
7381 #[test]
7382 fn ci_has_pending_gates_partial_ci() {
7383 let partial = serde_json::json!([
7386 {"name": "smoke", "bucket": "fail"},
7387 {"name": "rust-ci", "bucket": "pending"},
7388 ]);
7389 assert!(ci_has_pending_checks(&partial));
7390 let settled = serde_json::json!([
7392 {"name": "smoke", "bucket": "fail"},
7393 {"name": "rust-ci", "bucket": "pass"},
7394 {"name": "doc", "bucket": "skipping"},
7395 ]);
7396 assert!(!ci_has_pending_checks(&settled));
7397 let unknown = serde_json::json!([{"name": "x", "bucket": "queued"}]);
7399 assert!(ci_has_pending_checks(&unknown));
7400 assert!(!ci_has_pending_checks(&serde_json::json!({})));
7402 }
7403
7404 #[test]
7405 fn parse_failing_run_ids_only_failures_on_head_sha() {
7406 let list = serde_json::json!([
7410 {"databaseId": 1, "conclusion": "failure", "headSha": "head"},
7411 {"databaseId": 2, "conclusion": "success", "headSha": "head"},
7412 {"databaseId": 3, "conclusion": "cancelled", "headSha": "head"},
7413 {"databaseId": 4, "conclusion": "failure", "headSha": "old"},
7414 {"databaseId": 5, "conclusion": "failure", "headSha": "head"},
7415 ]);
7416 assert_eq!(parse_failing_run_ids(&list, "head"), vec![1, 5]);
7417 assert_eq!(parse_failing_run_ids(&list, "old"), vec![4]);
7419 }
7420
7421 #[test]
7422 fn parse_failing_job_names_only_failed_jobs() {
7423 let view = serde_json::json!({
7424 "jobs": [
7425 {"name": "codex", "conclusion": "success"},
7426 {"name": "cargo test + schema parity", "conclusion": "failure"},
7427 {"name": "gemini", "conclusion": "failure"},
7428 ]
7429 });
7430 let mut got = parse_failing_job_names(&view);
7431 got.sort();
7432 assert_eq!(
7433 got,
7434 vec![
7435 "cargo test + schema parity".to_string(),
7436 "gemini".to_string()
7437 ]
7438 );
7439 assert!(parse_failing_job_names(&serde_json::json!({})).is_empty());
7441 }
7442
7443 #[test]
7445 fn subset_rule_pr_failing_is_covered_by_main() {
7446 let pr = vec!["cargo test + schema parity".to_string()];
7447 let main = vec![
7448 "cargo test + schema parity".to_string(),
7449 "some other main-only red".to_string(),
7450 ];
7451 assert!(is_pre_existing_main_red(&pr, &main));
7452 }
7453
7454 #[test]
7456 fn subset_rule_pr_unique_red_blocks() {
7457 let pr = vec![
7458 "cargo test + schema parity".to_string(),
7459 "fmt gate".to_string(), ];
7461 let main = vec!["cargo test + schema parity".to_string()];
7462 assert!(!is_pre_existing_main_red(&pr, &main));
7463 }
7464
7465 #[test]
7466 fn subset_rule_empty_pr_failing_never_eligible() {
7467 assert!(!is_pre_existing_main_red(&[], &["x".to_string()]));
7469 assert!(!is_pre_existing_main_red(&["x".to_string()], &[]));
7471 }
7472
7473 #[test]
7474 fn already_emitted_awaiting_merge_detects_prior_and_absence() {
7475 let dir = tempfile::tempdir().unwrap();
7476 let events = dir.path().join("events.jsonl");
7477 assert!(!already_emitted_awaiting_merge(&events, "sess-A"));
7479 std::fs::write(
7481 &events,
7482 "{\"type\":\"termination\",\"data\":{\"session_id\":\"sess-A\",\"reason\":\"DonePRGreen\"}}\n",
7483 )
7484 .unwrap();
7485 assert!(!already_emitted_awaiting_merge(&events, "sess-A"));
7486 std::fs::write(
7488 &events,
7489 "{\"type\":\"termination\",\"data\":{\"session_id\":\"sess-A\",\"reason\":\"DoneAwaitingMerge\"}}\n",
7490 )
7491 .unwrap();
7492 assert!(already_emitted_awaiting_merge(&events, "sess-A"));
7493 assert!(!already_emitted_awaiting_merge(&events, "sess-B"));
7494 }
7495
7496 #[test]
7498 fn pr_state_parses_known_gh_strings() {
7499 assert_eq!(PrState::from_gh_str("OPEN"), PrState::Open);
7500 assert_eq!(PrState::from_gh_str("MERGED"), PrState::Merged);
7501 assert_eq!(PrState::from_gh_str("CLOSED"), PrState::Closed);
7502 assert_eq!(PrState::from_gh_str("none"), PrState::None);
7503 }
7504
7505 #[test]
7508 fn pr_state_unknown_string_fails_closed() {
7509 assert_eq!(PrState::from_gh_str("DRAFT"), PrState::None);
7510 assert_eq!(PrState::from_gh_str(""), PrState::None);
7511 assert_eq!(PrState::from_gh_str("open"), PrState::None);
7512 }
7513
7514 #[test]
7516 fn enum_rendering_byte_identical_to_legacy_strings() {
7517 assert_eq!(PrState::Open.as_str(), "OPEN");
7518 assert_eq!(PrState::Merged.as_str(), "MERGED");
7519 assert_eq!(PrState::Closed.as_str(), "CLOSED");
7520 assert_eq!(PrState::None.as_str(), "none");
7521 assert_eq!(CiConclusion::Success.render(), "SUCCESS");
7522 assert_eq!(
7523 CiConclusion::Failure(Some("lint".into())).render(),
7524 "FAILURE:lint"
7525 );
7526 assert_eq!(CiConclusion::Failure(None).render(), "FAILURE");
7527 assert_eq!(CiConclusion::Pending.render(), "PENDING");
7528 assert_eq!(CiConclusion::Skipped.render(), "skipped");
7529 assert_eq!(CiConclusion::None.render(), "none");
7530 }
7531
7532 #[test]
7534 fn parse_args_missing_required_flags_err() {
7535 let no_state: Vec<String> = vec![
7536 "loop-check".into(),
7537 "--transcript".into(),
7538 "/t".into(),
7539 "--cwd".into(),
7540 "/c".into(),
7541 ];
7542 assert_eq!(
7543 parse_args(&no_state).unwrap_err(),
7544 "--state is required".to_string()
7545 );
7546
7547 let no_transcript: Vec<String> = vec!["loop-check".into(), "--state".into(), "/s".into()];
7548 assert_eq!(
7549 parse_args(&no_transcript).unwrap_err(),
7550 "--transcript is required".to_string()
7551 );
7552
7553 let no_cwd: Vec<String> = vec![
7554 "loop-check".into(),
7555 "--state".into(),
7556 "/s".into(),
7557 "--transcript".into(),
7558 "/t".into(),
7559 ];
7560 assert_eq!(
7561 parse_args(&no_cwd).unwrap_err(),
7562 "--cwd is required".to_string()
7563 );
7564 }
7565
7566 #[test]
7568 fn parse_args_unknown_flag_tolerated() {
7569 let args: Vec<String> = vec![
7570 "loop-check".into(),
7571 "--state".into(),
7572 "/s".into(),
7573 "--transcript".into(),
7574 "/t".into(),
7575 "--cwd".into(),
7576 "/c".into(),
7577 "--future-flag=whatever".into(),
7578 "--another-unknown".into(),
7579 "value".into(),
7580 ];
7581 let parsed = parse_args(&args).expect("unknown flags must be ignored");
7582 assert_eq!(parsed.state_path, PathBuf::from("/s"));
7583 assert_eq!(parsed.transcript_path, PathBuf::from("/t"));
7584 assert_eq!(parsed.cwd, PathBuf::from("/c"));
7585 }
7586
7587 #[test]
7588 fn budget_flat_key_enforces_cost_cap_ab41b13d9d() {
7589 let settings_cfg = "budget_cap = 0.10\n";
7592 let settings = parse_settings(settings_cfg);
7593 assert_eq!(settings.flat_budget_cap, Some(Ok(0.10)));
7594 assert!(settings.attended_cost_cap_usd.is_none());
7596 assert!(settings.unattended_cost_cap_usd.is_none());
7597 let manifest_att = Manifest {
7601 session_id: Some("s1".into()),
7602 created_at: Some("2026-06-05T00:00:00Z".into()),
7603 attended: true,
7604 ..Default::default()
7605 };
7606 let manifest_unatt = Manifest {
7607 session_id: Some("s1".into()),
7608 created_at: Some("2026-06-05T00:00:00Z".into()),
7609 attended: false,
7610 ..Default::default()
7611 };
7612
7613 let tmp = tempfile::tempdir().unwrap();
7615 let ledger = tmp.path().join("ledger.json");
7616 std::fs::write(&ledger, r#"[{"session_id":"s1","cost_usd":0.50}]"#).unwrap();
7617
7618 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7619
7620 assert_eq!(
7621 check_budget(&manifest_att, &settings, &now, &ledger),
7622 Some(BudgetTrip::Cost),
7623 "flat budget_cap must enforce for attended"
7624 );
7625 assert_eq!(
7626 check_budget(&manifest_unatt, &settings, &now, &ledger),
7627 Some(BudgetTrip::Cost),
7628 "flat budget_cap must enforce for unattended"
7629 );
7630 }
7631
7632 #[test]
7633 fn is_bot_reviewer_known_patterns() {
7634 assert!(is_bot_reviewer("gemini-code-assist[bot]", &[]));
7635 assert!(is_bot_reviewer("chatgpt-codex-connector", &[]));
7636 assert!(is_bot_reviewer("some-bot[bot]", &[]));
7637 assert!(!is_bot_reviewer("human-reviewer", &[]));
7638 }
7639
7640 #[test]
7641 fn is_bot_reviewer_with_external_list() {
7642 let external = vec!["my-bot".to_string()];
7643 assert!(is_bot_reviewer("my-bot", &external));
7645 assert!(is_bot_reviewer("other-bot[bot]", &external));
7648 }
7649
7650 #[test]
7651 fn session_cost_from_ledger_sums_session_only() {
7652 let tmp = tempfile::tempdir().unwrap();
7653 let ledger = tmp.path().join("l.json");
7654 std::fs::write(
7655 &ledger,
7656 r#"[{"session_id":"a","cost_usd":1.0},{"session_id":"b","cost_usd":0.5},{"session_id":"a","cost_usd":0.25}]"#,
7657 )
7658 .unwrap();
7659 let cost = session_cost_from_ledger(&ledger, "a");
7660 assert!((cost - 1.25).abs() < 0.001, "expected 1.25, got {cost}");
7661 }
7662
7663 #[test]
7664 fn session_cost_missing_ledger_returns_zero() {
7665 let cost = session_cost_from_ledger(Path::new("/nonexistent/l.json"), "s");
7666 assert_eq!(cost, 0.0);
7667 }
7668
7669 #[test]
7670 fn allow_output_serializes_correctly() {
7671 let json = allow_output(
7672 "allow",
7673 Some(TerminationReason::DonePRGreen),
7674 "done",
7675 3,
7676 Some("fp".into()),
7677 );
7678 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
7679 assert_eq!(v["decision"], "allow");
7680 assert_eq!(v["termination_reason"], "DonePRGreen");
7682 assert_eq!(v["fires"], 3);
7683 assert_eq!(v["fingerprint"], "fp");
7684 }
7685
7686 #[test]
7687 fn allow_output_null_termination_reason() {
7688 let json = allow_output("block", None, "continue", 1, None);
7689 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
7690 assert!(v["termination_reason"].is_null());
7691 assert!(v["fingerprint"].is_null());
7692 }
7693
7694 #[test]
7695 fn watch_idle_event_is_non_terminal_allow() {
7696 let json = allow_output(
7702 "allow",
7703 None,
7704 "watching: idling until watcher fires (PR #404, ci pending)",
7705 3,
7706 Some("sha|OPEN|PENDING|none".to_string()),
7707 );
7708 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
7709 assert_eq!(v["decision"], "allow");
7710 assert!(
7711 v["termination_reason"].is_null(),
7712 "idle-allow MUST be non-terminal or finalize would run"
7713 );
7714 assert!(v["message"].as_str().unwrap().contains("watching"));
7715 }
7716
7717 #[test]
7718 fn termination_reason_variant_names_byte_identical() {
7719 let cases = [
7722 (TerminationReason::DonePRGreen, "DonePRGreen"),
7723 (TerminationReason::DoneAdvisory, "DoneAdvisory"),
7724 (TerminationReason::NoWork, "NoWork"),
7725 (TerminationReason::Budget, "Budget"),
7726 (TerminationReason::NoProgress, "NoProgress"),
7727 (TerminationReason::Interrupted, "Interrupted"),
7728 (TerminationReason::Aborted, "Aborted"),
7729 ];
7730 for (variant, expected) in cases {
7731 let json = serde_json::to_string(&variant).unwrap();
7732 assert_eq!(
7734 json,
7735 format!("\"{expected}\""),
7736 "variant {expected} serialized incorrectly"
7737 );
7738 }
7739 }
7740
7741 #[test]
7742 fn manifest_default_attended_is_true() {
7743 let m = Manifest::default();
7745 assert!(m.attended, "Manifest::default() must have attended=true");
7746 assert!(!m.advisory);
7747 assert!(!m.no_ship);
7748 assert!(!m.no_external);
7749 assert!(m.session_id.is_none());
7750 assert!(m.budget_cost_cap_usd.is_none());
7751 assert!(m.budget_wall_clock_cap_minutes.is_none());
7752 }
7753
7754 #[test]
7755 fn parse_manifest_malformed_cost_cap_fail_closed() {
7756 let content =
7758 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_cost_cap_usd: 5.OO\n---\n";
7759 let m = parse_manifest(content).unwrap();
7760 assert!(
7761 matches!(m.budget_cost_cap_usd, Some(Err(_))),
7762 "malformed cost cap must be Some(Err(...))"
7763 );
7764 }
7765
7766 #[test]
7767 fn parse_manifest_malformed_wall_cap_fail_closed() {
7768 let content =
7769 "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: abc\n---\n";
7770 let m = parse_manifest(content).unwrap();
7771 assert!(
7772 matches!(m.budget_wall_clock_cap_minutes, Some(Err(_))),
7773 "malformed wall cap must be Some(Err(...))"
7774 );
7775 }
7776
7777 #[test]
7778 fn parse_settings_malformed_flat_cap_fail_closed() {
7779 let cfg = "budget_cap = \"not_a_number\"\n";
7780 let s = parse_settings(cfg);
7781 assert!(
7782 matches!(s.flat_budget_cap, Some(Err(_))),
7783 "malformed flat_budget_cap must be Some(Err(...))"
7784 );
7785 }
7786
7787 #[test]
7788 fn check_budget_malformed_cost_cap_trips_budget() {
7789 let m = Manifest {
7791 session_id: Some("s".into()),
7792 created_at: Some("2026-06-05T00:00:00Z".into()),
7793 budget_cost_cap_usd: Some(Err("5.OO".into())),
7794 ..Default::default()
7795 };
7796 let s = Settings::default();
7797 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7798 let tmp = tempfile::tempdir().unwrap();
7799 let ledger = tmp.path().join("ledger.json");
7800 std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":0.0}]"#).unwrap();
7801 assert_eq!(
7802 check_budget(&m, &s, &now, &ledger),
7803 Some(BudgetTrip::Cost),
7804 "malformed cost cap must fail closed"
7805 );
7806 }
7807
7808 #[test]
7809 fn check_budget_absent_cap_is_unlimited() {
7810 let m = Manifest {
7812 session_id: Some("s".into()),
7813 created_at: Some("2026-06-05T00:00:00Z".into()),
7814 ..Default::default()
7815 };
7816 let s = Settings::default();
7817 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7818 let tmp = tempfile::tempdir().unwrap();
7819 let ledger = tmp.path().join("ledger.json");
7820 std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":9999.0}]"#).unwrap();
7821 assert_eq!(
7822 check_budget(&m, &s, &now, &ledger),
7823 None,
7824 "absent cap must be unlimited"
7825 );
7826 }
7827
7828 #[test]
7829 fn check_budget_negative_elapsed_no_trip() {
7830 let m = Manifest {
7832 session_id: Some("s".into()),
7833 created_at: Some("2026-06-05T02:00:00Z".into()),
7835 budget_wall_clock_cap_minutes: Some(Ok(30)),
7836 ..Default::default()
7837 };
7838 let s = Settings::default();
7839 let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
7841 let tmp = tempfile::tempdir().unwrap();
7842 let ledger = tmp.path().join("ledger.json");
7843 std::fs::write(&ledger, "[]").unwrap();
7844 assert_eq!(
7845 check_budget(&m, &s, &now, &ledger),
7846 None,
7847 "negative elapsed (future created_at) must not trip wall clock cap"
7848 );
7849 }
7850
7851 #[test]
7852 fn is_bot_reviewer_configured_short_names_match_real_logins() {
7853 let external = vec!["gemini".to_string(), "codex".to_string()];
7857 assert!(
7858 is_bot_reviewer("gemini-code-assist[bot]", &external),
7859 "gemini short name must substring-match gemini-code-assist[bot]"
7860 );
7861 assert!(
7862 is_bot_reviewer("chatgpt-codex-connector", &external),
7863 "codex short name must substring-match chatgpt-codex-connector"
7864 );
7865 }
7866
7867 #[test]
7868 fn is_bot_reviewer_configured_list_falls_back_to_bot_heuristic() {
7869 let external = vec!["some-human".to_string()];
7872 assert!(
7873 is_bot_reviewer("gemini-code-assist[bot]", &external),
7874 "configured list with no match must still fall back to [bot] heuristic"
7875 );
7876 }
7877
7878 #[test]
7879 fn is_bot_reviewer_empty_config_human_only_returns_false() {
7880 assert!(
7882 !is_bot_reviewer("alice-the-human", &[]),
7883 "human reviewer with empty config must return false"
7884 );
7885 }
7886
7887 #[test]
7890 fn parse_settings_required_bots_block_list() {
7891 let cfg = "[review]\nrequired_bots = [\n \"chatgpt-codex-connector\",\n \"gemini-code-assist\",\n]\n";
7892 let s = parse_settings(cfg);
7893 assert_eq!(
7894 s.required_bots,
7895 Some(vec![
7896 "chatgpt-codex-connector".to_string(),
7897 "gemini-code-assist".to_string()
7898 ])
7899 );
7900 }
7901
7902 #[test]
7903 fn parse_settings_required_bots_inline_empty_is_declared_empty() {
7904 let cfg = "[review]\nrequired_bots = []\n";
7907 let s = parse_settings(cfg);
7908 assert_eq!(s.required_bots, Some(Vec::new()));
7909 }
7910
7911 #[test]
7912 fn parse_settings_required_bots_inline_list() {
7913 let cfg = "[review]\nrequired_bots = [\"codex\", \"gemini\"]\n";
7914 let s = parse_settings(cfg);
7915 assert_eq!(
7916 s.required_bots,
7917 Some(vec!["codex".to_string(), "gemini".to_string()])
7918 );
7919 }
7920
7921 #[test]
7925 fn parse_settings_required_bots_scalar_is_singleton() {
7926 let cfg = "[review]\nrequired_bots = \"gemini\"\n";
7927 let s = parse_settings(cfg);
7928 assert_eq!(s.required_bots, Some(vec!["gemini".to_string()]));
7929 let g = parse_settings("[review]\ngithub_apps = \"chatgpt-codex-connector\"\n");
7931 assert_eq!(
7932 g.github_apps,
7933 Some(vec!["chatgpt-codex-connector".to_string()])
7934 );
7935 }
7936
7937 #[test]
7940 fn parse_settings_absent_required_bots_defaults() {
7941 let cfg = "[review]\ngithub_apps = []\n\n[ci]\ndeclared_none = true\n";
7942 let s = parse_settings(cfg);
7943 assert_eq!(
7944 s.required_bots, None,
7945 "absent key resolves to the no-gate default"
7946 );
7947 assert!(s.ci_declared_none, "following blocks still parse");
7948 }
7949
7950 #[test]
7953 fn parse_settings_required_bots_inline_comments_stripped() {
7954 let empty = parse_settings("[review]\nrequired_bots = [] # no review gate\n");
7955 assert_eq!(empty.required_bots, Some(Vec::new()));
7956
7957 let inline =
7958 parse_settings("[review]\nrequired_bots = [\"chatgpt-codex-connector\"] # required\n");
7959 assert_eq!(
7960 inline.required_bots,
7961 Some(vec!["chatgpt-codex-connector".to_string()])
7962 );
7963
7964 let block = parse_settings(
7965 "[review]\nrequired_bots = [ # the gate\n \"chatgpt-codex-connector\", # codex\n]\n",
7966 );
7967 assert_eq!(
7968 block.required_bots,
7969 Some(vec!["chatgpt-codex-connector".to_string()])
7970 );
7971
7972 let scalar = parse_settings("[review]\nrequired_bots = \"gemini\" # oops\n");
7975 assert_eq!(scalar.required_bots, Some(vec!["gemini".to_string()]));
7976 }
7977
7978 #[test]
7979 fn parse_settings_required_bots_multiline_array() {
7980 let cfg = "[review]\nrequired_bots = [\n \"chatgpt-codex-connector\",\n]\n";
7981 let s = parse_settings(cfg);
7982 assert_eq!(
7983 s.required_bots,
7984 Some(vec!["chatgpt-codex-connector".to_string()])
7985 );
7986 }
7987
7988 #[test]
7989 fn parse_settings_required_bots_reads_under_review_table() {
7990 let cfg = "[review]\nrequired_bots = [\"chatgpt-codex-connector\"]\n";
7992 let s = parse_settings(cfg);
7993 assert_eq!(
7994 s.required_bots,
7995 Some(vec!["chatgpt-codex-connector".to_string()])
7996 );
7997 }
7998
7999 #[test]
8000 fn parse_settings_malformed_fails_closed_not_zeroed() {
8001 let cfg = "[review\nrequired_bots = []\n";
8005 assert!(
8006 parse_settings_result(cfg).is_err(),
8007 "malformed TOML must be a parse error"
8008 );
8009 let s = parse_settings(cfg);
8010 assert_eq!(
8011 s.required_bots,
8012 Some(vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]),
8013 "a malformed file must fail closed, not zero the gate"
8014 );
8015 assert!(!login_matches_bot(
8017 "chatgpt-codex-connector",
8018 UNPARSEABLE_SETTINGS_SENTINEL
8019 ));
8020 }
8021
8022 #[test]
8023 fn parse_settings_unparseable_fails_closed() {
8024 let cfg = "[review]\nrequired_bots = [1, 2, 3\n"; assert!(parse_settings_result(cfg).is_err());
8029 let s = parse_settings(cfg);
8030 assert_eq!(
8031 resolved_required_bots(&s),
8032 vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]
8033 );
8034 }
8035
8036 #[test]
8037 fn resolved_required_bots_default_is_empty() {
8038 let s = Settings::default();
8041 assert!(
8042 resolved_required_bots(&s).is_empty(),
8043 "absent required_bots config must resolve to no review gate"
8044 );
8045 }
8046
8047 #[test]
8048 fn resolved_required_bots_explicit_list_wins() {
8049 let s = Settings {
8050 required_bots: Some(vec!["my-bot".to_string()]),
8051 ..Default::default()
8052 };
8053 assert_eq!(resolved_required_bots(&s), vec!["my-bot".to_string()]);
8054 let empty = Settings {
8055 required_bots: Some(Vec::new()),
8056 ..Default::default()
8057 };
8058 assert!(resolved_required_bots(&empty).is_empty());
8059 }
8060
8061 #[test]
8066 fn parse_settings_structural_scalar_degrades_like_python() {
8067 assert_eq!(scalar_as_singleton(" {login: codex}"), None);
8072 assert_eq!(scalar_as_singleton(" 123"), Some(vec!["123".to_string()]));
8073 let g = parse_settings("[review]\ngithub_apps = {login = \"codex\"}\n");
8074 assert_eq!(g.github_apps, None, "an inline table is not a login gate");
8075 let o = parse_settings("[review]\noptional_apps = {a = \"b\"}\n");
8076 assert_eq!(o.optional_apps, None);
8077 }
8078
8079 #[test]
8080 fn parse_settings_optional_apps_forms() {
8081 let inline = parse_settings("[review]\noptional_apps = [\"chatgpt-codex-connector\"]\n");
8083 assert_eq!(
8084 inline.optional_apps,
8085 Some(vec!["chatgpt-codex-connector".to_string()])
8086 );
8087 let block =
8088 parse_settings("[review]\noptional_apps = [\n \"chatgpt-codex-connector\",\n]\n");
8089 assert_eq!(
8090 block.optional_apps,
8091 Some(vec!["chatgpt-codex-connector".to_string()])
8092 );
8093 let scalar = parse_settings("[review]\noptional_apps = \"chatgpt-codex-connector\"\n");
8094 assert_eq!(
8095 scalar.optional_apps,
8096 Some(vec!["chatgpt-codex-connector".to_string()])
8097 );
8098 }
8099
8100 #[test]
8103 fn parse_settings_reviewers_forms() {
8104 let inline = parse_settings("[review]\nreviewers = [\"sigma\", \"/code-review\"]\n");
8107 assert_eq!(
8108 inline.reviewers,
8109 vec!["sigma".to_string(), "code-review".to_string()]
8110 );
8111 let block = parse_settings("[review]\nreviewers = [\n \"sigma\",\n]\n");
8112 assert_eq!(block.reviewers, vec!["sigma".to_string()]);
8113 let scalar = parse_settings("[review]\nreviewers = \"/code-review\"\n");
8114 assert_eq!(scalar.reviewers, vec!["code-review".to_string()]);
8115 let absent = parse_settings("[review]\ngithub_apps = []\n");
8116 assert!(absent.reviewers.is_empty());
8117 }
8118
8119 #[test]
8120 fn parse_settings_reviewers_distinct_from_external_reviewers() {
8121 let cfg = "external_reviewers = [\"gemini\"]\n\n[review]\nreviewers = [\"sigma\"]\n";
8124 let s = parse_settings(cfg);
8125 assert_eq!(s.external_reviewers, vec!["gemini".to_string()]);
8126 assert_eq!(s.reviewers, vec!["sigma".to_string()]);
8127 }
8128
8129 fn write_events(dir: &Path, lines: &[&str]) -> std::path::PathBuf {
8130 let p = dir.join("events.jsonl");
8131 std::fs::write(&p, lines.join("\n")).unwrap();
8132 p
8133 }
8134
8135 #[test]
8136 fn reviewers_all_attested_empty_is_vacuously_true() {
8137 let tmp = tempfile::tempdir().unwrap();
8138 let p = tmp.path().join("nonexistent.jsonl");
8139 assert!(reviewers_all_attested(&p, &[], "abc"));
8140 }
8141
8142 #[test]
8143 fn reviewers_all_attested_head_pinned_pass() {
8144 let tmp = tempfile::tempdir().unwrap();
8145 let p = write_events(
8146 tmp.path(),
8147 &[
8148 r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"abc123","verdict":"pass"}}"#,
8149 ],
8150 );
8151 assert!(reviewers_all_attested(&p, &["sigma".to_string()], "abc123"));
8152 }
8153
8154 #[test]
8155 fn reviewers_all_attested_stale_head_is_unsatisfied() {
8156 let tmp = tempfile::tempdir().unwrap();
8159 let p = write_events(
8160 tmp.path(),
8161 &[
8162 r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
8163 ],
8164 );
8165 assert!(!reviewers_all_attested(&p, &["sigma".to_string()], "NEW"));
8166 }
8167
8168 #[test]
8169 fn reviewers_all_attested_fail_and_missing_are_unsatisfied() {
8170 let tmp = tempfile::tempdir().unwrap();
8171 let fail = write_events(
8173 tmp.path(),
8174 &[
8175 r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
8176 ],
8177 );
8178 assert!(!reviewers_all_attested(&fail, &["sigma".to_string()], "h"));
8179 let gone = tmp.path().join("gone.jsonl");
8181 assert!(!reviewers_all_attested(&gone, &["sigma".to_string()], "h"));
8182 }
8183
8184 #[test]
8185 fn reviewers_all_attested_conjunction_and_slash_normalized() {
8186 let tmp = tempfile::tempdir().unwrap();
8189 let p = write_events(
8190 tmp.path(),
8191 &[
8192 r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
8193 r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"code-review","head_sha":"h","verdict":"pass"}}"#,
8194 ],
8195 );
8196 assert!(reviewers_all_attested(
8198 &p,
8199 &["sigma".to_string(), "/code-review".to_string()],
8200 "h"
8201 ));
8202 assert!(!reviewers_all_attested(
8204 &p,
8205 &["sigma".to_string(), "declare".to_string()],
8206 "h"
8207 ));
8208 }
8209
8210 #[test]
8211 fn parse_settings_reviewers_malformed_mapping_fails_closed() {
8212 let s = parse_settings("[review]\nreviewers = {a = \"b\"}\n");
8216 assert_eq!(s.reviewers, vec![MALFORMED_REVIEWERS_SENTINEL.to_string()]);
8217 let tmp = tempfile::tempdir().unwrap();
8218 let p = write_events(tmp.path(), &[]);
8219 assert!(
8220 !reviewers_all_attested(&p, &s.reviewers, "h"),
8221 "a malformed-reviewers sentinel must never be satisfiable"
8222 );
8223 }
8224
8225 #[test]
8226 fn parse_settings_reviewers_seq_with_nonscalar_fails_closed() {
8227 let bad = parse_settings("[review]\nreviewers = [\"sigma\", {a = \"b\"}]\n");
8231 assert_eq!(
8232 bad.reviewers,
8233 vec![MALFORMED_REVIEWERS_SENTINEL.to_string()]
8234 );
8235 let ok = parse_settings("[review]\nreviewers = [\"sigma\", \"declare\"]\n");
8237 assert_eq!(
8238 ok.reviewers,
8239 vec!["sigma".to_string(), "declare".to_string()]
8240 );
8241 }
8242
8243 #[test]
8244 fn reviewers_all_attested_latest_verdict_wins() {
8245 let tmp = tempfile::tempdir().unwrap();
8248 let pf = write_events(
8250 tmp.path(),
8251 &[
8252 r#"{"ts":"t1","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
8253 r#"{"ts":"t2","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
8254 ],
8255 );
8256 assert!(
8257 !reviewers_all_attested(&pf, &["sigma".to_string()], "h"),
8258 "a fail posted after a pass must revoke it"
8259 );
8260 let fp = write_events(
8262 tmp.path(),
8263 &[
8264 r#"{"ts":"t1","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
8265 r#"{"ts":"t2","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
8266 ],
8267 );
8268 assert!(
8269 reviewers_all_attested(&fp, &["sigma".to_string()], "h"),
8270 "a pass posted after a fail must restore satisfaction"
8271 );
8272 }
8273
8274 #[test]
8277 fn review_finding_open_then_resolved_clears() {
8278 let tmp = tempfile::tempdir().unwrap();
8280 let open = write_events(
8281 tmp.path(),
8282 &[
8283 r#"{"ts":"t1","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-1","text":"off-by-one in the loop\nsecond line"}}"#,
8284 ],
8285 );
8286 let (findings, malformed) = open_review_findings(&open, "x-1");
8287 assert_eq!(malformed, 0);
8288 assert_eq!(findings.len(), 1);
8289 assert_eq!(findings[0].id, "f1");
8290 assert_eq!(findings[0].first_line, "off-by-one in the loop"); let resolved = write_events(
8294 tmp.path(),
8295 &[
8296 r#"{"ts":"t1","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-1","text":"off-by-one"}}"#,
8297 r#"{"ts":"t2","type":"review_finding_resolved","source":"observer","data":{"finding_id":"f1"}}"#,
8298 ],
8299 );
8300 assert!(open_review_findings(&resolved, "x-1").0.is_empty());
8301 }
8302
8303 #[test]
8304 fn review_finding_is_node_scoped() {
8305 let tmp = tempfile::tempdir().unwrap();
8307 let p = write_events(
8308 tmp.path(),
8309 &[
8310 r#"{"ts":"t","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-OTHER","text":"not mine"}}"#,
8311 ],
8312 );
8313 assert!(open_review_findings(&p, "x-mine").0.is_empty());
8314 assert_eq!(open_review_findings(&p, "x-OTHER").0.len(), 1);
8315 }
8316
8317 #[test]
8318 fn review_finding_malformed_notices_not_blocks() {
8319 let tmp = tempfile::tempdir().unwrap();
8323 let truncated = r#"{"ts":"t","type":"review_finding","data":{"finding_id":"f1"#;
8325 let id_less = r#"{"ts":"t","type":"review_finding","source":"observer","data":{"node":"x-1","text":"no id"}}"#;
8326 let good = r#"{"ts":"t","type":"review_finding","source":"observer","data":{"finding_id":"good","node":"x-1","text":"real one"}}"#;
8327 let p = write_events(tmp.path(), &[truncated, id_less, good]);
8328 let (findings, malformed) = open_review_findings(&p, "x-1");
8329 assert_eq!(findings.len(), 1, "only the well-formed finding gates");
8330 assert_eq!(findings[0].id, "good");
8331 assert_eq!(
8332 malformed, 2,
8333 "the truncated line + the id-less line are noticed"
8334 );
8335 }
8336
8337 #[test]
8338 fn review_finding_block_reason_quotes_first_plus_count() {
8339 let open = vec![
8340 OpenFinding {
8341 id: "aaa".into(),
8342 first_line: "the bug".into(),
8343 },
8344 OpenFinding {
8345 id: "bbb".into(),
8346 first_line: "another".into(),
8347 },
8348 ];
8349 let r = build_findings_block_reason(&open, 1);
8350 assert!(r.contains("aaa"));
8351 assert!(r.contains("the bug"));
8352 assert!(r.contains("fno annotate resolve aaa"));
8353 assert!(r.contains("[+1 more]"));
8354 assert!(r.contains("1 malformed"));
8355 }
8356
8357 #[test]
8358 fn resolved_optional_is_separate_from_required() {
8359 let s = parse_settings(
8362 "[review]\ngithub_apps = []\noptional_apps = [\"chatgpt-codex-connector\"]\n",
8363 );
8364 assert!(
8365 resolved_required_bots(&s).is_empty(),
8366 "optional must not be required"
8367 );
8368 assert_eq!(
8369 resolved_optional_bots(&s),
8370 vec!["chatgpt-codex-connector".to_string()]
8371 );
8372 }
8373
8374 #[test]
8375 fn parse_settings_github_apps_block_list() {
8376 let cfg = "[review]\ngithub_apps = [\n \"chatgpt-codex-connector\",\n]\n";
8377 let s = parse_settings(cfg);
8378 assert_eq!(
8379 s.github_apps,
8380 Some(vec!["chatgpt-codex-connector".to_string()])
8381 );
8382 }
8383
8384 #[test]
8385 fn parse_settings_github_apps_inline_and_empty() {
8386 let s = parse_settings("[review]\ngithub_apps = [\"a\", \"b\"]\n");
8387 assert_eq!(s.github_apps, Some(vec!["a".to_string(), "b".to_string()]));
8388 let e = parse_settings("[review]\ngithub_apps = []\n");
8389 assert_eq!(e.github_apps, Some(Vec::new()));
8390 }
8391
8392 #[test]
8393 fn resolved_github_apps_wins_over_required_bots_alias() {
8394 let s = Settings {
8396 github_apps: Some(vec!["new-bot".to_string()]),
8397 required_bots: Some(vec!["old-bot".to_string()]),
8398 ..Default::default()
8399 };
8400 assert_eq!(resolved_required_bots(&s), vec!["new-bot".to_string()]);
8401 let legacy = Settings {
8403 required_bots: Some(vec!["old-bot".to_string()]),
8404 ..Default::default()
8405 };
8406 assert_eq!(resolved_required_bots(&legacy), vec!["old-bot".to_string()]);
8407 }
8408
8409 #[test]
8412 fn parse_settings_peers_inline_scalars() {
8413 let cfg = "[review]\npeers = [\"codex\", \"gemini\"]\npeer_identity = \"fno-peer-bot\"\n";
8414 let s = parse_settings(cfg);
8415 assert_eq!(s.peers.len(), 2);
8416 assert_eq!(s.peers[0].provider, "codex");
8417 assert_eq!(s.peer_identity.as_deref(), Some("fno-peer-bot"));
8418 }
8419
8420 #[test]
8421 fn parse_settings_peers_block_maps_with_identity() {
8422 let cfg = "[review]\npeers = [{provider = \"codex\", identity = \"fno-codex-bot\"}, \"gemini\"]\n";
8424 let s = parse_settings(cfg);
8425 assert_eq!(s.peers.len(), 2);
8426 assert_eq!(s.peers[0].provider, "codex");
8427 assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8428 assert_eq!(s.peers[1].provider, "gemini");
8429 assert_eq!(s.peers[1].identity, None);
8430 }
8431
8432 #[test]
8433 fn resolved_peers_shared_identity_collapses_to_one_login() {
8434 let s = Settings {
8437 github_apps: Some(Vec::new()),
8438 peers: vec![
8439 PeerEntry {
8440 provider: "codex".into(),
8441 model: None,
8442 identity: None,
8443 },
8444 PeerEntry {
8445 provider: "gemini".into(),
8446 model: None,
8447 identity: None,
8448 },
8449 ],
8450 peer_identity: Some("fno-peer-bot".into()),
8451 ..Default::default()
8452 };
8453 assert_eq!(resolved_required_bots(&s), vec!["fno-peer-bot".to_string()]);
8454 }
8455
8456 #[test]
8457 fn resolved_peers_per_entry_identities_each_add_a_login() {
8458 let s = Settings {
8459 github_apps: Some(vec!["chatgpt-codex-connector".into()]),
8460 peers: vec![
8461 PeerEntry {
8462 provider: "codex".into(),
8463 model: None,
8464 identity: Some("fno-codex-bot".into()),
8465 },
8466 PeerEntry {
8467 provider: "gemini".into(),
8468 model: None,
8469 identity: Some("fno-gemini-bot".into()),
8470 },
8471 ],
8472 ..Default::default()
8473 };
8474 assert_eq!(
8475 resolved_required_bots(&s),
8476 vec![
8477 "chatgpt-codex-connector".to_string(),
8478 "fno-codex-bot".to_string(),
8479 "fno-gemini-bot".to_string(),
8480 ]
8481 );
8482 }
8483
8484 #[test]
8485 fn parse_settings_github_apps_and_peers_together() {
8486 let cfg = "[review]\ngithub_apps = [\"chatgpt-codex-connector\"]\npeers = [\"codex\"]\npeer_identity = \"fno-peer-bot\"\n";
8488 let s = parse_settings(cfg);
8489 assert_eq!(
8490 s.github_apps,
8491 Some(vec!["chatgpt-codex-connector".to_string()]),
8492 "github_apps item must be collected"
8493 );
8494 assert_eq!(s.peers.len(), 1, "peers item must be collected");
8495 assert_eq!(s.peers[0].provider, "codex");
8496 assert_eq!(s.peer_identity.as_deref(), Some("fno-peer-bot"));
8497 }
8498
8499 #[test]
8500 fn parse_settings_required_bots_single_item() {
8501 let cfg = "[review]\nrequired_bots = [\"chatgpt-codex-connector\"]\n";
8502 let s = parse_settings(cfg);
8503 assert_eq!(
8504 s.required_bots,
8505 Some(vec!["chatgpt-codex-connector".to_string()])
8506 );
8507 }
8508
8509 #[test]
8510 fn parse_settings_peers_single_mapping_is_one_peer() {
8511 let block = parse_settings(
8515 "[review]\npeers = {provider = \"codex\", identity = \"fno-codex-bot\"}\n",
8516 );
8517 assert_eq!(block.peers.len(), 1, "table peers must be one peer");
8518 assert_eq!(block.peers[0].provider, "codex");
8519 assert_eq!(block.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8520 let dotted = parse_settings(
8522 "[review.peers]\nprovider = \"gemini\"\nidentity = \"fno-gemini-bot\"\n",
8523 );
8524 assert_eq!(dotted.peers.len(), 1);
8525 assert_eq!(dotted.peers[0].provider, "gemini");
8526 assert_eq!(dotted.peers[0].identity.as_deref(), Some("fno-gemini-bot"));
8527 }
8528
8529 #[test]
8530 fn parse_settings_peers_bare_scalar_is_one_provider() {
8531 let cfg = "[review]\npeers = \"codex\"\npeer_identity = \"fno-peer-bot\"\n";
8534 let s = parse_settings(cfg);
8535 assert_eq!(s.peers.len(), 1);
8536 assert_eq!(s.peers[0].provider, "codex");
8537 assert_eq!(resolved_required_bots(&s), vec!["fno-peer-bot".to_string()]);
8539 }
8540
8541 #[test]
8542 fn parse_settings_peers_array_of_tables() {
8543 let cfg = "[review]\npeers = [{provider = \"codex\", identity = \"fno-codex-bot\"}, \"gemini\"]\n";
8545 let s = parse_settings(cfg);
8546 assert_eq!(s.peers.len(), 2);
8547 assert_eq!(s.peers[0].provider, "codex");
8548 assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8549 assert_eq!(s.peers[1].provider, "gemini");
8550 }
8551
8552 #[test]
8553 fn parse_settings_peers_map_identity_before_provider() {
8554 let cfg = "[review]\npeers = [{identity = \"fno-codex-bot\", provider = \"codex\"}, {provider = \"gemini\", identity = \"fno-gemini-bot\"}]\n";
8557 let s = parse_settings(cfg);
8558 assert_eq!(s.peers.len(), 2);
8559 assert_eq!(s.peers[0].provider, "codex");
8560 assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
8561 assert_eq!(s.peers[1].provider, "gemini");
8562 assert_eq!(s.peers[1].identity.as_deref(), Some("fno-gemini-bot"));
8563 }
8564
8565 #[test]
8566 fn identity_free_peer_uses_local_attestation_not_a_login() {
8567 let s = Settings {
8568 github_apps: Some(Vec::new()),
8569 peers: vec![PeerEntry {
8570 provider: "gemini".into(),
8571 model: None,
8572 identity: None,
8573 }],
8574 peer_identity: None,
8575 ..Default::default()
8576 };
8577 assert!(resolved_required_bots_for_author(&s, Some("codex")).is_empty());
8578 assert_eq!(
8579 resolved_local_peer_reviewers_for_author(&s, Some("codex")),
8580 vec![LOCAL_PEER_REVIEWER.to_string()]
8581 );
8582 }
8583
8584 #[test]
8585 fn identity_free_same_model_peer_is_an_unsatisfiable_local_gate() {
8586 let s = Settings {
8587 peers: vec![PeerEntry {
8588 provider: "codex".into(),
8589 model: None,
8590 identity: None,
8591 }],
8592 ..Default::default()
8593 };
8594 assert_eq!(
8595 resolved_local_peer_reviewers_for_author(&s, Some("codex")),
8596 vec![SAME_MODEL_LOCAL_PEER_SENTINEL.to_string()]
8597 );
8598 }
8599
8600 #[test]
8601 fn identity_free_mixed_peers_form_one_composite_gate() {
8602 let s = Settings {
8603 peers: vec![
8604 PeerEntry {
8605 provider: "codex".into(),
8606 model: None,
8607 identity: None,
8608 },
8609 PeerEntry {
8610 provider: "claude".into(),
8611 model: Some("zai,glm-5.2".into()),
8612 identity: None,
8613 },
8614 ],
8615 ..Default::default()
8616 };
8617 assert_eq!(
8618 resolved_local_peer_reviewers_for_author(&s, Some("codex")),
8619 vec![LOCAL_PEER_REVIEWER.to_string()]
8620 );
8621 }
8622
8623 #[test]
8624 fn explicit_peer_identity_keeps_login_gate_only() {
8625 let s = Settings {
8626 peers: vec![PeerEntry {
8627 provider: "gemini".into(),
8628 model: None,
8629 identity: Some("fno-gemini-bot".into()),
8630 }],
8631 ..Default::default()
8632 };
8633 assert_eq!(
8634 resolved_required_bots_for_author(&s, Some("codex")),
8635 vec!["fno-gemini-bot".to_string()]
8636 );
8637 assert!(resolved_local_peer_reviewers_for_author(&s, Some("codex")).is_empty());
8638 }
8639
8640 #[test]
8641 fn local_peer_attestation_is_head_pinned() {
8642 let td = tempfile::tempdir().unwrap();
8643 let events = td.path().join("events.jsonl");
8644 std::fs::write(
8645 &events,
8646 r#"{"type":"review_attestation","data":{"reviewer":"peer","head_sha":"OLD","verdict":"pass"}}"#,
8647 )
8648 .unwrap();
8649 let peer = vec![LOCAL_PEER_REVIEWER.to_string()];
8650 assert!(!reviewers_all_attested(&events, &peer, "NEW"));
8651 std::fs::write(
8652 &events,
8653 r#"{"type":"review_attestation","data":{"reviewer":"peer","head_sha":"NEW","verdict":"pass"}}"#,
8654 )
8655 .unwrap();
8656 assert!(reviewers_all_attested(&events, &peer, "NEW"));
8657 }
8658
8659 #[test]
8664 fn peer_family_mapping_table() {
8665 let bare = |p: &str| PeerEntry {
8666 provider: p.into(),
8667 model: None,
8668 identity: None,
8669 };
8670 let routed = |p: &str, m: &str| PeerEntry {
8671 provider: p.into(),
8672 model: Some(m.into()),
8673 identity: None,
8674 };
8675 assert_eq!(harness_family("claude"), Some("anthropic"));
8677 assert_eq!(harness_family("ANTHROPIC"), Some("anthropic"));
8678 assert_eq!(harness_family("codex"), Some("openai"));
8679 assert_eq!(harness_family("gemini"), Some("google"));
8680 assert_eq!(harness_family("zai"), None);
8681 assert_eq!(route_provider("zai,glm-5.2"), Some("zai"));
8683 assert_eq!(route_provider(" openai , gpt-5 "), Some("openai"));
8684 assert_eq!(route_provider("gpt-5"), None); assert_eq!(route_provider("zai,"), None); assert_eq!(route_provider(",glm"), None); assert_eq!(route_provider("a,b,c"), None); assert_eq!(peer_family(&bare("codex")), Some("openai"));
8691 assert_eq!(peer_family(&bare("grok")), None); assert_eq!(peer_family(&routed("claude", "zai,glm-5.2")), None); assert_eq!(
8694 peer_family(&routed("codex", "openai,gpt-5")),
8695 Some("openai")
8696 );
8697 assert_eq!(peer_family(&routed("codex", "gpt-5")), Some("openai")); }
8699
8700 #[test]
8703 fn same_model_peer_holds_gate() {
8704 let s = Settings {
8705 github_apps: Some(Vec::new()),
8706 peers: vec![PeerEntry {
8707 provider: "codex".into(),
8708 model: None,
8709 identity: None,
8710 }],
8711 peer_identity: Some("fno-peer-bot".into()),
8712 ..Default::default()
8713 };
8714 let logins = resolved_required_bots_for_author(&s, Some("codex"));
8715 assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
8716 assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
8717 }
8718
8719 #[test]
8722 fn cross_model_peer_login_unchanged() {
8723 let s = Settings {
8724 github_apps: Some(Vec::new()),
8725 peers: vec![PeerEntry {
8726 provider: "gemini".into(),
8727 model: None,
8728 identity: None,
8729 }],
8730 peer_identity: Some("fno-peer-bot".into()),
8731 ..Default::default()
8732 };
8733 let logins = resolved_required_bots_for_author(&s, Some("codex"));
8734 assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
8735 }
8736
8737 #[test]
8741 fn routed_claude_peer_is_cross_model() {
8742 let s = Settings {
8743 github_apps: Some(Vec::new()),
8744 peers: vec![PeerEntry {
8745 provider: "claude".into(),
8746 model: Some("zai,glm-5.2".into()),
8747 identity: None,
8748 }],
8749 peer_identity: Some("fno-peer-bot".into()),
8750 ..Default::default()
8751 };
8752 let logins = resolved_required_bots_for_author(&s, Some("claude"));
8753 assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
8754 }
8755
8756 #[test]
8759 fn same_family_route_holds_gate() {
8760 let s = Settings {
8761 github_apps: Some(Vec::new()),
8762 peers: vec![PeerEntry {
8763 provider: "claude".into(),
8764 model: Some("anthropic,claude-opus".into()),
8765 identity: None,
8766 }],
8767 peer_identity: Some("fno-peer-bot".into()),
8768 ..Default::default()
8769 };
8770 let logins = resolved_required_bots_for_author(&s, Some("claude"));
8771 assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
8772 assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
8773 }
8774
8775 #[test]
8778 fn shared_identity_mixed_peers_stays_satisfiable() {
8779 let s = Settings {
8780 github_apps: Some(Vec::new()),
8781 peers: vec![
8782 PeerEntry {
8783 provider: "codex".into(),
8784 model: None,
8785 identity: None,
8786 },
8787 PeerEntry {
8788 provider: "gemini".into(),
8789 model: None,
8790 identity: None,
8791 },
8792 ],
8793 peer_identity: Some("fno-peer-bot".into()),
8794 ..Default::default()
8795 };
8796 let logins = resolved_required_bots_for_author(&s, Some("codex"));
8797 assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
8798 }
8799
8800 #[test]
8803 fn unknown_harness_is_byte_identical() {
8804 let s = Settings {
8805 github_apps: Some(vec!["chatgpt-codex-connector".into()]),
8806 peers: vec![PeerEntry {
8807 provider: "codex".into(),
8808 model: None,
8809 identity: None,
8810 }],
8811 peer_identity: Some("fno-peer-bot".into()),
8812 ..Default::default()
8813 };
8814 assert_eq!(
8816 resolved_required_bots_for_author(&s, None),
8817 resolved_required_bots(&s)
8818 );
8819 assert!(!resolved_required_bots_for_author(&s, None)
8820 .iter()
8821 .any(|l| l == SAME_MODEL_PEER_SENTINEL));
8822 }
8823
8824 #[test]
8829 fn base_app_login_collision_is_fail_closed() {
8830 let s = Settings {
8831 github_apps: Some(vec!["fno-peer-bot".into()]),
8832 peers: vec![PeerEntry {
8833 provider: "codex".into(),
8834 model: None,
8835 identity: None,
8836 }],
8837 peer_identity: Some("fno-peer-bot".into()),
8838 ..Default::default()
8839 };
8840 let logins = resolved_required_bots_for_author(&s, Some("codex"));
8841 assert!(logins.iter().any(|l| l == "fno-peer-bot")); assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL)); }
8844
8845 #[test]
8850 fn non_claude_route_is_ignored() {
8851 let routed_codex = PeerEntry {
8852 provider: "codex".into(),
8853 model: Some("zai,glm-5.2".into()),
8854 identity: None,
8855 };
8856 assert_eq!(peer_family(&routed_codex), Some("openai"));
8857 let s = Settings {
8858 github_apps: Some(Vec::new()),
8859 peers: vec![routed_codex],
8860 peer_identity: Some("fno-peer-bot".into()),
8861 ..Default::default()
8862 };
8863 let logins = resolved_required_bots_for_author(&s, Some("codex"));
8864 assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
8865 assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
8866 }
8867
8868 #[test]
8869 fn login_matches_bot_cases() {
8870 assert!(login_matches_bot(
8872 "chatgpt-codex-connector",
8873 "chatgpt-codex-connector"
8874 ));
8875 assert!(login_matches_bot(
8876 "chatgpt-codex-connector[bot]",
8877 "chatgpt-codex-connector"
8878 ));
8879 assert!(login_matches_bot("chatgpt-codex-connector", "codex"));
8880 assert!(login_matches_bot("Gemini-Code-Assist[bot]", "gemini"));
8881 assert!(!login_matches_bot("alice-the-human", "codex"));
8882 assert!(!login_matches_bot("anyone", ""));
8884 }
8885
8886 #[test]
8887 fn compute_review_info_per_bot_verdict() {
8888 let required = vec![
8889 "chatgpt-codex-connector".to_string(),
8890 "gemini-code-assist".to_string(),
8891 ];
8892 let json = serde_json::json!({
8894 "reviews": [
8895 {"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
8896 "submittedAt": "2026-06-05T01:00:00Z"}
8897 ],
8898 "comments": []
8899 });
8900 let info = compute_review_info(&json, &required);
8901 assert!(!info.all_required_passed());
8902 assert_eq!(info.missing_bots, vec!["gemini-code-assist".to_string()]);
8903 assert_eq!(info.latest_ts, "2026-06-05T01:00:00Z");
8904 }
8905
8906 fn nudge_cfg() -> NudgeConfig {
8909 NudgeConfig {
8910 login: "chatgpt-codex-connector".into(),
8911 review_handle: "@codex review".into(),
8912 wait_minutes: 15,
8913 ceiling: 3,
8914 }
8915 }
8916 fn nudge_now() -> DateTime<Utc> {
8917 "2026-07-06T02:00:00Z".parse().unwrap()
8918 }
8919 fn mention(body: &str, created: &str) -> Value {
8920 serde_json::json!({"body": body, "createdAt": created})
8921 }
8922
8923 #[test]
8924 fn nudge_needs_nudge_when_never_mentioned() {
8925 let cfg = nudge_cfg();
8926 let b = classify_bot_nudge("chatgpt-codex-connector", &[], Some(&cfg), nudge_now());
8927 assert_eq!(b.class, NudgeClass::NeedsNudge);
8928 assert_eq!(b.nudges, 0);
8929 assert_eq!(b.review_handle, "@codex review");
8930 }
8931
8932 #[test]
8933 fn nudge_awaiting_within_window() {
8934 let cfg = nudge_cfg();
8935 let comments = vec![mention("@codex review", "2026-07-06T01:58:00Z")];
8936 let b = classify_bot_nudge(
8937 "chatgpt-codex-connector",
8938 &comments,
8939 Some(&cfg),
8940 nudge_now(),
8941 );
8942 assert_eq!(b.class, NudgeClass::Awaiting);
8943 assert_eq!(b.nudges, 1);
8944 assert!(b.newest_age_min <= 2);
8945 }
8946
8947 #[test]
8948 fn nudge_unresponsive_after_ceiling() {
8949 let cfg = nudge_cfg();
8951 let comments = vec![
8952 mention("@codex review", "2026-07-06T00:00:00Z"),
8953 mention("hey @codex review please", "2026-07-06T00:30:00Z"),
8954 mention("@codex review", "2026-07-06T01:00:00Z"),
8955 ];
8956 let b = classify_bot_nudge(
8957 "chatgpt-codex-connector",
8958 &comments,
8959 Some(&cfg),
8960 nudge_now(),
8961 );
8962 assert_eq!(b.class, NudgeClass::Unresponsive);
8963 assert_eq!(b.nudges, 3);
8964 assert!(b.span_min >= 120, "span was {}", b.span_min);
8965 }
8966
8967 #[test]
8968 fn nudge_reask_after_timeout_below_ceiling() {
8969 let cfg = nudge_cfg();
8971 let comments = vec![mention("@codex review", "2026-07-06T01:00:00Z")];
8972 let b = classify_bot_nudge(
8973 "chatgpt-codex-connector",
8974 &comments,
8975 Some(&cfg),
8976 nudge_now(),
8977 );
8978 assert_eq!(b.class, NudgeClass::NeedsNudge);
8979 assert_eq!(b.nudges, 1);
8980 }
8981
8982 #[test]
8983 fn nudge_none_cfg_is_not_nudgeable() {
8984 let b2 = classify_bot_nudge(SAME_MODEL_PEER_SENTINEL, &[], None, nudge_now());
8986 assert_eq!(b2.class, NudgeClass::NotNudgeable);
8987 }
8988
8989 #[test]
8990 fn nudge_malformed_created_at_is_needs_nudge() {
8991 let cfg = nudge_cfg();
8993 let comments = vec![mention("@codex review", "not-a-date")];
8994 let b = classify_bot_nudge(
8995 "chatgpt-codex-connector",
8996 &comments,
8997 Some(&cfg),
8998 nudge_now(),
8999 );
9000 assert_eq!(b.class, NudgeClass::NeedsNudge);
9001 assert_eq!(b.nudges, 1);
9002 }
9003
9004 #[test]
9005 fn resolved_nudge_configs_default_nudges_codex_only() {
9006 let cfgs = resolved_nudge_configs(&Settings::default());
9007 let codex = cfgs
9008 .iter()
9009 .find(|c| c.login == "chatgpt-codex-connector")
9010 .expect("codex nudgeable by default");
9011 assert_eq!(codex.review_handle, "@codex review");
9012 assert_eq!(codex.wait_minutes, 15);
9013 assert_eq!(codex.ceiling, 3);
9014 assert!(cfgs.iter().all(|c| c.login != "gemini-code-assist"));
9016 }
9017
9018 #[test]
9019 fn nudge_override_sets_wait_and_ceiling_inheriting_handle() {
9020 let s = parse_settings(
9021 "[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = 30, ceiling = 5 }\n",
9022 );
9023 let cfgs = resolved_nudge_configs(&s);
9024 let codex = cfgs
9025 .iter()
9026 .find(|c| logins_correspond(&c.login, "chatgpt-codex-connector"))
9027 .unwrap();
9028 assert_eq!(codex.wait_minutes, 30);
9029 assert_eq!(codex.ceiling, 5);
9030 assert_eq!(codex.review_handle, "@codex review");
9031 }
9032
9033 #[test]
9034 fn nudge_override_disabled_removes_login() {
9035 let s =
9036 parse_settings("[review.nudge]\n\"chatgpt-codex-connector\" = { enabled = false }\n");
9037 let cfgs = resolved_nudge_configs(&s);
9038 assert!(cfgs
9039 .iter()
9040 .all(|c| !logins_correspond(&c.login, "chatgpt-codex-connector")));
9041 }
9042
9043 #[test]
9044 fn nudge_override_new_login() {
9045 let s = parse_settings(
9046 "[review.nudge]\n\"some-bot\" = { review_handle = \"@somebot review\", wait_minutes = 10, ceiling = 2 }\n",
9047 );
9048 let cfgs = resolved_nudge_configs(&s);
9049 let b = cfgs.iter().find(|c| c.login == "some-bot").unwrap();
9050 assert_eq!(b.review_handle, "@somebot review");
9051 assert_eq!(b.wait_minutes, 10);
9052 assert_eq!(b.ceiling, 2);
9053 }
9054
9055 #[test]
9056 fn nudge_malformed_override_degrades_to_non_nudgeable() {
9057 for body in [
9060 "[review.nudge]\n\"chatgpt-codex-connector\" = \"scalar\"\n",
9061 "[review.nudge]\n\"chatgpt-codex-connector\" = [1, 2]\n",
9062 "[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = \"soon\" }\n",
9063 "[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = 9999999999999999 }\n",
9066 ] {
9067 let s = parse_settings(body);
9068 let cfgs = resolved_nudge_configs(&s);
9069 assert!(
9070 cfgs.iter()
9071 .all(|c| !logins_correspond(&c.login, "chatgpt-codex-connector")),
9072 "malformed override must be non-nudgeable: {body}"
9073 );
9074 }
9075 }
9076
9077 #[test]
9078 fn compute_review_info_empty_state_not_a_pass() {
9079 let required = vec!["chatgpt-codex-connector".to_string()];
9081 let json = serde_json::json!({
9082 "reviews": [
9083 {"author": {"login": "chatgpt-codex-connector"}, "state": "",
9084 "submittedAt": "2026-06-05T01:00:00Z"}
9085 ],
9086 "comments": []
9087 });
9088 let info = compute_review_info(&json, &required);
9089 assert!(!info.all_required_passed());
9090 }
9091
9092 #[test]
9093 fn compute_review_info_usage_limited_bot_dropped() {
9094 let required = vec!["chatgpt-codex-connector".to_string()];
9097 let json = serde_json::json!({
9098 "reviews": [],
9099 "comments": [
9100 {"author": {"login": "chatgpt-codex-connector"},
9101 "body": "You have reached your Codex usage limits for code reviews.",
9102 "createdAt": "2026-07-06T01:00:00Z"}
9103 ]
9104 });
9105 let info = compute_review_info(&json, &required);
9106 assert!(info.missing_bots.is_empty());
9107 assert_eq!(
9108 info.usage_limited,
9109 vec!["chatgpt-codex-connector".to_string()]
9110 );
9111 assert!(info.all_required_passed());
9112 }
9113
9114 #[test]
9115 fn compute_review_info_usage_limit_only_own_comment_counts() {
9116 let required = vec!["chatgpt-codex-connector".to_string()];
9119 let json = serde_json::json!({
9120 "reviews": [],
9121 "comments": [
9122 {"author": {"login": "some-human"},
9123 "body": "The bot hit its usage limits for code reviews, ugh.",
9124 "createdAt": "2026-07-06T01:00:00Z"}
9125 ]
9126 });
9127 let info = compute_review_info(&json, &required);
9128 assert_eq!(
9129 info.missing_bots,
9130 vec!["chatgpt-codex-connector".to_string()]
9131 );
9132 assert!(info.usage_limited.is_empty());
9133 assert!(!info.all_required_passed());
9134 }
9135
9136 #[test]
9137 fn compute_review_info_real_review_beats_ratelimit_comment() {
9138 let required = vec!["chatgpt-codex-connector".to_string()];
9142 let json = serde_json::json!({
9143 "reviews": [
9144 {"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
9145 "submittedAt": "2026-07-06T02:00:00Z"}
9146 ],
9147 "comments": [
9148 {"author": {"login": "chatgpt-codex-connector"},
9149 "body": "codex usage limits reached",
9150 "createdAt": "2026-07-06T01:00:00Z"}
9151 ]
9152 });
9153 let info = compute_review_info(&json, &required);
9154 assert!(info.missing_bots.is_empty());
9155 assert!(info.usage_limited.is_empty());
9156 assert!(info.all_required_passed());
9157 }
9158
9159 #[test]
9162 fn blocking_severity_codex_p1_both_forms() {
9163 assert_eq!(
9165 blocking_severity(" Bug"),
9166 Some("P1")
9167 );
9168 assert_eq!(blocking_severity("![P1 Badge] something"), Some("P1"));
9170 assert_eq!(
9171 blocking_severity("see https://img.shields.io/badge/P1-orange"),
9172 Some("P1")
9173 );
9174 }
9175
9176 #[test]
9177 fn blocking_severity_codex_p2_p3_advisory() {
9178 assert_eq!(
9179 blocking_severity(" nit"),
9180 None
9181 );
9182 assert_eq!(
9183 blocking_severity(" nit"),
9184 None
9185 );
9186 }
9187
9188 #[test]
9189 fn blocking_severity_gemini_critical_high_blocking() {
9190 assert_eq!(
9191 blocking_severity(
9192 " bad"
9193 ),
9194 Some("critical")
9195 );
9196 assert_eq!(
9197 blocking_severity(
9198 " bad"
9199 ),
9200 Some("high")
9201 );
9202 }
9203
9204 #[test]
9205 fn blocking_severity_gemini_medium_low_advisory() {
9206 assert_eq!(
9207 blocking_severity(
9208 " hmm"
9209 ),
9210 None
9211 );
9212 assert_eq!(
9213 blocking_severity(
9214 " hmm"
9215 ),
9216 None
9217 );
9218 }
9219
9220 #[test]
9223 fn blocking_severity_unparseable_is_advisory() {
9224 assert_eq!(blocking_severity("just a comment with no badge"), None);
9225 assert_eq!(blocking_severity(""), None);
9226 assert_eq!(blocking_severity("P1 mentioned in prose only"), None);
9227 }
9228
9229 #[test]
9230 fn max_ts_none_handling() {
9231 assert_eq!(
9232 max_ts("none", "2026-06-05T01:00:00Z"),
9233 "2026-06-05T01:00:00Z"
9234 );
9235 assert_eq!(
9236 max_ts("2026-06-05T01:00:00Z", "none"),
9237 "2026-06-05T01:00:00Z"
9238 );
9239 assert_eq!(max_ts("none", "none"), "none");
9240 assert_eq!(max_ts("", ""), "none");
9241 assert_eq!(
9242 max_ts("2026-06-05T01:00:00Z", "2026-06-05T02:00:00Z"),
9243 "2026-06-05T02:00:00Z"
9244 );
9245 }
9246
9247 fn finding_comment(id: i64, body: &str, created_at: &str) -> Value {
9248 serde_json::json!({
9249 "id": id,
9250 "in_reply_to_id": null,
9251 "user": {"login": "chatgpt-codex-connector[bot]"},
9252 "body": body,
9253 "path": "src/x.rs",
9254 "line": 42,
9255 "created_at": created_at
9256 })
9257 }
9258
9259 fn reply_comment(id: i64, parent: i64, login: &str, body: &str, created_at: &str) -> Value {
9260 serde_json::json!({
9261 "id": id,
9262 "in_reply_to_id": parent,
9263 "user": {"login": login},
9264 "body": body,
9265 "created_at": created_at
9266 })
9267 }
9268
9269 const REQ: &[&str] = &["chatgpt-codex-connector"];
9270
9271 fn req_vec() -> Vec<String> {
9272 REQ.iter().map(|s| s.to_string()).collect()
9273 }
9274
9275 #[test]
9277 fn finding_no_reply_is_unaddressed() {
9278 let comments = vec bug",
9281 "2026-06-05T01:10:00Z",
9282 )];
9283 let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9284 assert_eq!(ts, "2026-06-05T01:10:00Z");
9285 assert_eq!(unaddressed.len(), 1);
9286 assert_eq!(unaddressed[0].path, "src/x.rs");
9287 assert_eq!(unaddressed[0].line, 42);
9288 assert_eq!(unaddressed[0].severity, "P1");
9289 }
9290
9291 #[test]
9293 fn finding_reply_plus_commit_after_is_addressed() {
9294 let comments = vec bug",
9298 "2026-06-05T01:10:00Z",
9299 ),
9300 reply_comment(
9301 101,
9302 100,
9303 "bllshttng",
9304 "fixed in abc123",
9305 "2026-06-05T01:20:00Z",
9306 ),
9307 ];
9308 let commits = vec!["2026-06-05T01:30:00Z".to_string()];
9309 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9310 assert!(unaddressed.is_empty(), "commit-after arm must address");
9311 }
9312
9313 #[test]
9315 fn finding_wontfix_reply_is_addressed_without_commit() {
9316 let comments = vec bug",
9320 "2026-06-05T01:10:00Z",
9321 ),
9322 reply_comment(
9323 101,
9324 100,
9325 "bllshttng",
9326 "wontfix: intentional - documented tradeoff",
9327 "2026-06-05T01:20:00Z",
9328 ),
9329 ];
9330 let commits = vec!["2026-06-05T01:00:00Z".to_string()];
9332 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9333 assert!(unaddressed.is_empty(), "wontfix arm must address alone");
9334 }
9335
9336 #[test]
9339 fn finding_commit_without_reply_is_unaddressed() {
9340 let comments = vec bug",
9343 "2026-06-05T01:10:00Z",
9344 )];
9345 let commits = vec!["2026-06-05T01:30:00Z".to_string()];
9346 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9347 assert_eq!(unaddressed.len(), 1, "commit alone must not address");
9348 }
9349
9350 #[test]
9352 fn finding_bot_reply_only_is_unaddressed() {
9353 let comments = vec bug",
9357 "2026-06-05T01:10:00Z",
9358 ),
9359 reply_comment(
9360 101,
9361 100,
9362 "chatgpt-codex-connector[bot]",
9363 "elaborating on my finding",
9364 "2026-06-05T01:15:00Z",
9365 ),
9366 ];
9367 let commits = vec!["2026-06-05T01:30:00Z".to_string()];
9368 let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9369 assert_eq!(unaddressed.len(), 1, "bot self-reply must not count as ack");
9370 }
9371
9372 #[test]
9374 fn finding_reply_without_commit_or_wontfix_is_unaddressed() {
9375 let comments = vec bug",
9379 "2026-06-05T01:10:00Z",
9380 ),
9381 reply_comment(
9382 101,
9383 100,
9384 "bllshttng",
9385 "looking into it",
9386 "2026-06-05T01:20:00Z",
9387 ),
9388 ];
9389 let commits = vec!["2026-06-05T01:00:00Z".to_string()]; let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
9391 assert_eq!(unaddressed.len(), 1);
9392 }
9393
9394 #[test]
9396 fn finding_from_non_required_bot_ignored() {
9397 let comments = vec![serde_json::json!({
9398 "id": 200,
9399 "in_reply_to_id": null,
9400 "user": {"login": "gemini-code-assist[bot]"},
9401 "body": " eh",
9402 "path": "src/y.rs",
9403 "line": 7,
9404 "created_at": "2026-06-05T01:10:00Z"
9405 })];
9406 let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9408 assert!(unaddressed.is_empty());
9409 assert_eq!(ts, "2026-06-05T01:10:00Z");
9411 }
9412
9413 #[test]
9415 fn empty_comments_no_findings() {
9416 let (ts, unaddressed) = compute_unaddressed_findings(&[], &[], &req_vec(), &[]);
9417 assert_eq!(ts, "none");
9418 assert!(unaddressed.is_empty());
9419 }
9420
9421 #[test]
9425 fn finding_missing_id_skipped_not_pooled() {
9426 let no_id = serde_json::json!({
9427 "in_reply_to_id": null,
9428 "user": {"login": "chatgpt-codex-connector[bot]"},
9429 "body": " idless",
9430 "path": "src/z.rs", "line": 3,
9431 "created_at": "2026-06-05T01:05:00Z"
9432 });
9433 let real = finding_comment(
9434 100,
9435 " real",
9436 "2026-06-05T01:10:00Z",
9437 );
9438 let stray = reply_comment(
9440 101,
9441 0,
9442 "bllshttng",
9443 "wontfix: stray",
9444 "2026-06-05T01:20:00Z",
9445 );
9446 let comments = vec![no_id, real, stray];
9447 let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9448 assert_eq!(unaddressed.len(), 1, "only the real finding remains");
9449 assert_eq!(unaddressed[0].id, 100);
9450 }
9451
9452 #[test]
9457 fn ts_after_parses_offsets_correctly() {
9458 assert!(!ts_after(
9461 "2026-06-05T23:30:00+13:00",
9462 "2026-06-05T11:00:00Z"
9463 ));
9464 assert!(ts_after(
9470 "2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"
9472 ));
9473 assert!(ts_after("2026-06-05T11:00:01Z", "2026-06-05T11:00:00Z"));
9474 assert!(!ts_after("2026-06-05T11:00:00Z", "2026-06-05T11:00:00Z"));
9475 assert!(!ts_after("garbage", "2026-06-05T11:00:00Z"));
9477 assert!(!ts_after("2026-06-05T11:00:00Z", "garbage"));
9478 assert!(!ts_after("2026-06-05T11:00:00Z", ""));
9479 }
9480
9481 #[test]
9485 fn max_ts_chronological_with_offsets() {
9486 assert_eq!(
9488 max_ts("2026-06-05T23:30:00+13:00", "2026-06-05T11:00:00Z"),
9489 "2026-06-05T11:00:00Z"
9490 );
9491 assert_eq!(
9493 max_ts("2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"),
9494 "2026-06-05T23:30:00+10:00"
9495 );
9496 }
9497
9498 #[test]
9502 fn finding_reply_listed_before_finding_still_addressed() {
9503 let comments = vec bug",
9514 "2026-06-05T01:10:00Z",
9515 ),
9516 ];
9517 let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
9518 assert!(
9519 unaddressed.is_empty(),
9520 "reply-before-finding ordering must still ack"
9521 );
9522 }
9523
9524 #[test]
9527 fn no_pr_stderr_detected() {
9528 assert!(is_no_pr_stderr(
9529 b"no pull requests found for branch \"feat\""
9530 ));
9531 assert!(is_no_pr_stderr(b"No pull requests found for branch \"x\""));
9532 assert!(!is_no_pr_stderr(b"connect: network is unreachable"));
9534 assert!(!is_no_pr_stderr(b"API rate limit exceeded"));
9535 assert!(!is_no_pr_stderr(b""));
9536 }
9537}
9538
9539#[cfg(test)]
9540mod done_probe_tests {
9541 use super::*;
9542 use std::time::Duration;
9543
9544 fn fm(body: &str) -> String {
9545 format!("---\ntitle: t\n{body}\n---\n\n# doc\n")
9546 }
9547
9548 fn probes_of(doc: &str) -> Vec<String> {
9549 match parse_done_probes(doc) {
9550 ProbeDecl::Probes(p) => p,
9551 other => panic!("expected probes, got {other:?}"),
9552 }
9553 }
9554
9555 #[test]
9556 fn parses_block_list() {
9557 let doc = fm("done_probes:\n - \"fno mail list --since 24h | grep -q groom\"\n - 'echo ok'\nstatus: ready");
9558 assert_eq!(
9559 probes_of(&doc),
9560 vec![
9561 "fno mail list --since 24h | grep -q groom".to_string(),
9562 "echo ok".to_string()
9563 ]
9564 );
9565 }
9566
9567 #[test]
9568 fn parses_inline_list_keeping_commas_inside_commands() {
9569 let doc = fm(r#"done_probes: ["gh api x --jq '.a,.b'", "echo ok"]"#);
9570 assert_eq!(
9571 probes_of(&doc),
9572 vec!["gh api x --jq '.a,.b'".to_string(), "echo ok".to_string()],
9573 "a comma inside a quoted command must not split it into two probes"
9574 );
9575 }
9576
9577 #[test]
9578 fn absent_field_and_explicit_empty_list_are_both_no_gate() {
9579 assert_eq!(parse_done_probes(&fm("done_probes: []")), ProbeDecl::None);
9580 assert_eq!(parse_done_probes(&fm("status: ready")), ProbeDecl::None);
9581 assert_eq!(parse_done_probes("no frontmatter here"), ProbeDecl::None);
9582 }
9583
9584 #[test]
9585 fn a_declaration_this_parser_cannot_read_is_never_no_gate() {
9586 let multiline_inline = fm("done_probes: [\n \"echo a\",\n \"echo b\"\n]");
9589 assert_eq!(parse_done_probes(&multiline_inline), ProbeDecl::Unparseable);
9590 assert_eq!(
9591 parse_done_probes(&fm("done_probes:\nstatus: ready")),
9592 ProbeDecl::Unparseable,
9593 "a declared-but-empty block must refuse, not pass"
9594 );
9595 }
9596
9597 #[test]
9598 fn inline_list_keeps_escaped_quotes_inside_a_command() {
9599 let doc = fm(r#"done_probes: ["sh -c \"echo hi\"", "echo ok"]"#);
9602 assert_eq!(
9603 probes_of(&doc),
9604 vec![r#"sh -c "echo hi""#.to_string(), "echo ok".to_string()]
9605 );
9606 }
9607
9608 #[test]
9609 fn inline_list_preserves_a_trailing_bracket_and_refuses_an_unterminated_one() {
9610 assert_eq!(
9611 probes_of(&fm(r#"done_probes: ["echo [hi]"]"#)),
9612 vec!["echo [hi]".to_string()],
9613 "only the list's own closing bracket may be stripped"
9614 );
9615 assert_eq!(
9616 parse_done_probes(&fm(r#"done_probes: ["echo a""#)),
9617 ProbeDecl::Unparseable,
9618 "an unterminated inline list must refuse, not silently parse"
9619 );
9620 }
9621
9622 #[test]
9623 fn a_comment_inside_the_block_does_not_swallow_the_probes() {
9624 let doc = fm("done_probes:\n # why this probe exists\n - echo a\n - echo b\ntags: []");
9625 assert_eq!(
9626 probes_of(&doc),
9627 vec!["echo a".to_string(), "echo b".to_string()]
9628 );
9629 }
9630
9631 #[test]
9632 fn block_list_stops_at_the_next_key() {
9633 let doc = fm("done_probes:\n - echo a\ntags: []\nother: x");
9634 assert_eq!(probes_of(&doc), vec!["echo a".to_string()]);
9635 }
9636
9637 #[test]
9638 fn probe_outcomes_render_pass_fail_and_exit_code() {
9639 let tmp = tempfile::tempdir().unwrap();
9640 let t = Duration::from_secs(10);
9641 assert_eq!(run_probe("exit 0", tmp.path(), t).render(), "pass");
9642 assert_eq!(run_probe("exit 3", tmp.path(), t).render(), "fail:3");
9643 assert_eq!(
9644 run_probe("fno-no-such-binary-xyz", tmp.path(), t).render(),
9645 "fail:127",
9646 "a missing binary must fail closed as 127, never pass"
9647 );
9648 }
9649
9650 #[test]
9651 fn hanging_probe_is_killed_within_the_timeout_budget() {
9652 let tmp = tempfile::tempdir().unwrap();
9653 let start = std::time::Instant::now();
9654 let outcome = run_probe("sleep 30", tmp.path(), Duration::from_millis(200));
9655 assert_eq!(outcome.render(), "timeout");
9656 assert!(
9657 start.elapsed() < Duration::from_secs(5),
9658 "run_probe must return on its own timeout, not wait out the child"
9659 );
9660 }
9661
9662 #[test]
9663 fn chatty_probe_does_not_deadlock_on_the_stderr_pipe() {
9664 let tmp = tempfile::tempdir().unwrap();
9667 let outcome = run_probe(
9668 "head -c 200000 /dev/zero | tr '\\0' 'x' >&2; exit 1",
9669 tmp.path(),
9670 Duration::from_secs(20),
9671 );
9672 assert_eq!(outcome.render(), "fail:1");
9673 match outcome {
9674 ProbeOutcome::Fail { stderr, .. } => assert!(
9675 stderr.len() <= PROBE_STDERR_CAP,
9676 "stderr must be truncated to {PROBE_STDERR_CAP}"
9677 ),
9678 _ => panic!("expected Fail"),
9679 }
9680 }
9681
9682 #[test]
9683 fn over_cap_declaration_refuses_without_running_anything() {
9684 let tmp = tempfile::tempdir().unwrap();
9685 let plan = tmp.path().join("plan.md");
9686 let sentinel = tmp.path().join("ran");
9687 std::fs::write(
9688 &plan,
9689 fm(&format!(
9690 "done_probes:\n - touch {0}\n - echo b\n - echo c\n - echo d",
9691 sentinel.display()
9692 )),
9693 )
9694 .unwrap();
9695 let events = tmp.path().join("events.jsonl");
9696 match evaluate_done_probes(
9697 plan.to_str(),
9698 None,
9699 tmp.path(),
9700 &events,
9701 "s1",
9702 Duration::from_secs(10),
9703 ) {
9704 ProbeGate::Fail { reason, .. } => {
9705 assert!(
9706 reason.contains("cap is 3"),
9707 "reason names the cap: {reason}"
9708 )
9709 }
9710 _ => panic!("over-cap declaration must refuse"),
9711 }
9712 assert!(!sentinel.exists(), "an over-cap list must not execute");
9713 }
9714
9715 #[test]
9716 fn unreadable_plan_fails_closed_only_when_probes_were_seen_before() {
9717 let tmp = tempfile::tempdir().unwrap();
9718 let events = tmp.path().join("events.jsonl");
9719 let missing = tmp.path().join("gone.md");
9720
9721 assert!(matches!(
9723 evaluate_done_probes(
9724 missing.to_str(),
9725 None,
9726 tmp.path(),
9727 &events,
9728 "s1",
9729 Duration::from_secs(10)
9730 ),
9731 ProbeGate::Absent
9732 ));
9733
9734 std::fs::write(
9736 &events,
9737 "{\"type\":\"loop_check\",\"data\":{\"session_id\":\"s1\",\"done_probes\":{\"echo ok\":\"pass\"}}}\n",
9738 )
9739 .unwrap();
9740 match evaluate_done_probes(
9741 missing.to_str(),
9742 None,
9743 tmp.path(),
9744 &events,
9745 "s1",
9746 Duration::from_secs(10),
9747 ) {
9748 ProbeGate::Fail { reason, .. } => assert!(
9749 reason.contains("undeterminable"),
9750 "reason must say undeterminable: {reason}"
9751 ),
9752 _ => panic!("unreadable plan with probe history must fail closed"),
9753 }
9754 }
9755
9756 #[test]
9757 fn a_refusal_where_nothing_ran_still_records_probe_history() {
9758 let tmp = tempfile::tempdir().unwrap();
9761 let plan = tmp.path().join("plan.md");
9762 std::fs::write(
9763 &plan,
9764 fm("done_probes:\n - echo a\n - echo b\n - echo c\n - echo d"),
9765 )
9766 .unwrap();
9767 let events = tmp.path().join("events.jsonl");
9768 let ProbeGate::Fail { results, .. } = evaluate_done_probes(
9769 plan.to_str(),
9770 None,
9771 tmp.path(),
9772 &events,
9773 "s1",
9774 Duration::from_secs(10),
9775 ) else {
9776 panic!("over-cap must refuse");
9777 };
9778 std::fs::write(
9779 &events,
9780 format!(
9781 "{}\n",
9782 serde_json::json!({
9783 "type": "loop_check",
9784 "data": {"session_id": "s1", "done_probes": results}
9785 })
9786 ),
9787 )
9788 .unwrap();
9789 assert!(
9790 prior_fires_declared_probes(&events, "s1"),
9791 "a declared-but-never-ran refusal must be visible as probe history"
9792 );
9793 }
9794
9795 #[test]
9796 fn relative_plan_path_resolves_against_the_session_cwd() {
9797 let tmp = tempfile::tempdir().unwrap();
9800 std::fs::write(tmp.path().join("plan.md"), fm("done_probes:\n - exit 0")).unwrap();
9801 let events = tmp.path().join("events.jsonl");
9802 assert!(
9803 matches!(
9804 evaluate_done_probes(
9805 Some("plan.md"),
9806 None,
9807 tmp.path(),
9808 &events,
9809 "s1",
9810 Duration::from_secs(10)
9811 ),
9812 ProbeGate::Pass(_)
9813 ),
9814 "a relative plan_path must resolve against cwd, not the process cwd"
9815 );
9816 }
9817
9818 #[test]
9819 fn timeout_reaches_the_gate_reason() {
9820 let tmp = tempfile::tempdir().unwrap();
9821 let plan = tmp.path().join("plan.md");
9822 std::fs::write(&plan, fm("done_probes:\n - sleep 30")).unwrap();
9823 let events = tmp.path().join("events.jsonl");
9824 match evaluate_done_probes(
9825 plan.to_str(),
9826 None,
9827 tmp.path(),
9828 &events,
9829 "s1",
9830 Duration::from_millis(200),
9831 ) {
9832 ProbeGate::Fail { reason, results } => {
9833 assert!(
9834 reason.contains("timed out"),
9835 "reason names the timeout: {reason}"
9836 );
9837 assert_eq!(results["sleep 30"], "timeout");
9838 }
9839 _ => panic!("a hanging probe must refuse done"),
9840 }
9841 }
9842
9843 #[test]
9844 fn a_pipeline_probe_timeout_does_not_hang_the_gate() {
9845 let tmp = tempfile::tempdir().unwrap();
9849 let start = std::time::Instant::now();
9850 let outcome = run_probe("sleep 30 | cat", tmp.path(), Duration::from_millis(200));
9851 assert_eq!(outcome.render(), "timeout");
9852 assert!(
9853 start.elapsed() < Duration::from_secs(10),
9854 "a pipeline probe must not outlive its timeout (took {:?})",
9855 start.elapsed()
9856 );
9857 }
9858
9859 #[test]
9860 fn multibyte_stderr_is_truncated_without_panicking() {
9861 let mut s = "→".repeat(400); keep_last_on_char_boundary(&mut s, PROBE_STDERR_CAP);
9865 assert!(s.len() <= PROBE_STDERR_CAP);
9866 assert!(s.chars().all(|c| c == '→'), "must not split a character");
9867 }
9868
9869 #[test]
9870 fn stderr_cap_keeps_the_tail_where_the_error_is() {
9871 let mut s = format!("{}\nthe actual error", "noise ".repeat(200));
9872 keep_last_on_char_boundary(&mut s, PROBE_STDERR_CAP);
9873 assert!(
9874 s.ends_with("the actual error"),
9875 "the last line is the diagnostic; keeping the prefix drops it: {s}"
9876 );
9877 }
9878
9879 #[test]
9880 fn block_scalar_escapes_decode_to_the_command_the_plan_meant() {
9881 let doc = fm("done_probes:\n - \"test -n \\\"$(echo hi)\\\"\"");
9884 assert_eq!(probes_of(&doc), vec![r#"test -n "$(echo hi)""#.to_string()]);
9885 }
9886
9887 #[test]
9888 fn single_quoted_scalar_undoubles_its_quote() {
9889 let doc = fm("done_probes:\n - 'echo it''s fine'");
9890 assert_eq!(probes_of(&doc), vec!["echo it's fine".to_string()]);
9891 }
9892
9893 #[test]
9894 fn plan_path_fragment_is_stripped_before_reading() {
9895 let tmp = tempfile::tempdir().unwrap();
9898 std::fs::write(tmp.path().join("plan.md"), fm("done_probes:\n - exit 0")).unwrap();
9899 let events = tmp.path().join("events.jsonl");
9900 assert!(
9901 matches!(
9902 evaluate_done_probes(
9903 Some("plan.md#wave-1"),
9904 None,
9905 tmp.path(),
9906 &events,
9907 "s1",
9908 Duration::from_secs(10)
9909 ),
9910 ProbeGate::Pass(_)
9911 ),
9912 "a fragment in plan_path must not silently disable the gate"
9913 );
9914 }
9915
9916 #[test]
9917 fn a_backgrounding_probe_does_not_block_the_drain() {
9918 let tmp = tempfile::tempdir().unwrap();
9921 let start = std::time::Instant::now();
9922 let outcome = run_probe("sleep 300 & exit 0", tmp.path(), Duration::from_secs(30));
9923 assert_eq!(outcome.render(), "pass");
9924 assert!(
9925 start.elapsed() < Duration::from_secs(10),
9926 "a backgrounded descendant must not hold the drain open (took {:?})",
9927 start.elapsed()
9928 );
9929 }
9930
9931 fn project(cmds: &[&str]) -> Result<Vec<String>, String> {
9938 Ok(cmds.iter().map(|c| c.to_string()).collect())
9939 }
9940
9941 fn bare_plan(dir: &Path) -> std::path::PathBuf {
9943 let plan = dir.join("plan.md");
9944 std::fs::write(&plan, fm("title: p")).unwrap();
9945 plan
9946 }
9947
9948 #[test]
9949 fn a_project_probe_gates_a_plan_that_declares_none() {
9950 let tmp = tempfile::tempdir().unwrap();
9953 let plan = bare_plan(tmp.path());
9954 let events = tmp.path().join("events.jsonl");
9955 match evaluate_done_probes(
9956 plan.to_str(),
9957 Some(&project(&["true"])),
9958 tmp.path(),
9959 &events,
9960 "s1",
9961 Duration::from_secs(10),
9962 ) {
9963 ProbeGate::Pass(results) => assert_eq!(results["true"], "pass"),
9964 _ => panic!("a passing project probe must let the gate through"),
9965 }
9966 }
9967
9968 #[test]
9969 fn a_failing_project_probe_blocks_and_names_its_source() {
9970 let tmp = tempfile::tempdir().unwrap();
9973 let plan = bare_plan(tmp.path());
9974 let events = tmp.path().join("events.jsonl");
9975 match evaluate_done_probes(
9976 plan.to_str(),
9977 Some(&project(&["false"])),
9978 tmp.path(),
9979 &events,
9980 "s1",
9981 Duration::from_secs(10),
9982 ) {
9983 ProbeGate::Fail { reason, .. } => assert!(
9984 reason.contains("project probe `false`"),
9985 "the reason must name the source: {reason}"
9986 ),
9987 _ => panic!("a failing project probe must block"),
9988 }
9989 }
9990
9991 #[test]
9998 fn an_unparseable_project_declaration_blocks_rather_than_degrading() {
9999 let tmp = tempfile::tempdir().unwrap();
10002 let plan = bare_plan(tmp.path());
10003 let events = tmp.path().join("events.jsonl");
10004 let junk: Result<Vec<String>, String> = value_as_probe_list(
10005 &"done_probes = { a = 1 }".parse::<toml::Value>().unwrap()["done_probes"],
10006 );
10007 assert!(junk.is_err(), "a mapping is not a probe list");
10008 match evaluate_done_probes(
10009 plan.to_str(),
10010 Some(&junk),
10011 tmp.path(),
10012 &events,
10013 "s1",
10014 Duration::from_secs(10),
10015 ) {
10016 ProbeGate::Fail { reason, results } => {
10017 assert!(
10018 reason.contains("undeterminable"),
10019 "must use the plan side's vocabulary: {reason}"
10020 );
10021 assert_eq!(results["_undeterminable"], "unparseable-config-declaration");
10022 }
10023 _ => panic!("an unreadable project declaration must block"),
10024 }
10025 }
10026
10027 #[test]
10028 fn the_cap_is_per_source_not_per_union() {
10029 let tmp = tempfile::tempdir().unwrap();
10033 let plan = tmp.path().join("plan.md");
10034 std::fs::write(
10035 &plan,
10036 fm("done_probes:\n - echo d\n - echo e\n - echo f"),
10037 )
10038 .unwrap();
10039 let events = tmp.path().join("events.jsonl");
10040 match evaluate_done_probes(
10041 plan.to_str(),
10042 Some(&project(&["echo a", "echo b", "echo c"])),
10043 tmp.path(),
10044 &events,
10045 "s1",
10046 Duration::from_secs(10),
10047 ) {
10048 ProbeGate::Pass(results) => assert_eq!(
10049 results.as_object().unwrap().len(),
10050 6,
10051 "all six probes must run: {results}"
10052 ),
10053 other => panic!(
10054 "3 + 3 is within the per-source cap: {}",
10055 match other {
10056 ProbeGate::Fail { reason, .. } => reason,
10057 _ => "Absent".to_string(),
10058 }
10059 ),
10060 }
10061
10062 match evaluate_done_probes(
10064 plan.to_str(),
10065 Some(&project(&["true", "true", "true", "true"])),
10066 tmp.path(),
10067 &events,
10068 "s1",
10069 Duration::from_secs(10),
10070 ) {
10071 ProbeGate::Fail { reason, .. } => assert!(
10072 reason.contains("config.toml declares 4") && reason.contains("per source"),
10073 "an over-cap project list must refuse loudly: {reason}"
10074 ),
10075 _ => panic!("4 project probes must refuse"),
10076 }
10077 }
10078
10079 #[test]
10080 fn no_declaration_on_either_source_stays_absent() {
10081 let tmp = tempfile::tempdir().unwrap();
10083 let plan = bare_plan(tmp.path());
10084 let events = tmp.path().join("events.jsonl");
10085 assert!(matches!(
10086 evaluate_done_probes(
10087 plan.to_str(),
10088 Some(&project(&[])),
10089 tmp.path(),
10090 &events,
10091 "s1",
10092 Duration::from_secs(10)
10093 ),
10094 ProbeGate::Absent
10095 ));
10096 }
10097
10098 #[test]
10099 fn config_done_probes_parses_off_the_flat_root() {
10100 let s = parse_settings("done_probes = [\"make a11y-check\"]\n");
10103 assert_eq!(s.done_probes, Some(Ok(vec!["make a11y-check".to_string()])),);
10104 assert_eq!(parse_settings("plans_dir = \"x\"\n").done_probes, None);
10105 assert!(parse_settings("done_probes = \"nope\"\n")
10106 .done_probes
10107 .unwrap()
10108 .is_err());
10109 assert!(parse_settings("done_probes = [1]\n")
10110 .done_probes
10111 .unwrap()
10112 .is_err());
10113 }
10114}