use crate::{completion_output::allow_output, delivery_completion::pr_passes};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TerminationReason {
DonePRGreen,
DoneAdvisory,
DoneDelivery,
DoneBatched,
DoneAwaitingMerge,
DonePlanned,
NoWork,
Budget,
NoProgress,
Interrupted,
Aborted,
}
#[derive(Debug)]
struct Manifest {
session_id: Option<String>,
created_at: Option<String>,
attended: bool, advisory: bool,
no_ship: bool,
no_external: bool,
batched: bool,
planned: bool,
plan_path: Option<String>,
legacy_status: Option<String>, budget_wall_clock_cap_minutes: Option<Result<u64, String>>,
budget_cost_cap_usd: Option<Result<f64, String>>,
}
impl Default for Manifest {
fn default() -> Self {
Self {
session_id: None,
created_at: None,
attended: true, advisory: false,
no_ship: false,
no_external: false,
batched: false,
planned: false,
plan_path: None,
legacy_status: None,
budget_wall_clock_cap_minutes: None, budget_cost_cap_usd: None, }
}
}
fn scan_manifest_field(content: &str, field: &str) -> Option<String> {
let prefix = format!("{field}:");
content.lines().find_map(|line| {
let line = line.trim();
line.strip_prefix(&prefix)
.map(|v| v.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
.filter(|v| !v.is_empty())
})
}
fn parse_manifest(content: &str) -> Option<Manifest> {
let content = content.trim_start();
if !content.starts_with("---") {
return None;
}
let after_first = &content[3..];
let end = after_first.find("\n---")?;
let body = &after_first[..end];
let mut m = Manifest {
attended: true, ..Default::default()
};
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once(':') {
let k = k.trim();
let v = v.trim().trim_matches(|c| c == '"' || c == '\'');
match k {
"fno_id" => m.session_id = Some(v.to_string()),
"session_id" => {
if m.session_id.is_none() {
m.session_id = Some(v.to_string());
}
}
"created_at" => m.created_at = Some(v.to_string()),
"attended" => m.attended = v == "true",
"advisory" => m.advisory = v == "true",
"no_ship" => m.no_ship = v == "true",
"no_external" => m.no_external = v == "true",
"batched" => m.batched = v == "true",
"planned" => m.planned = v == "true",
"plan_path" => {
if !v.is_empty() {
m.plan_path = Some(v.to_string());
}
}
"status" => {
let upper = v.to_uppercase();
if matches!(upper.as_str(), "COMPLETE" | "BLOCKED" | "ABORTED") {
m.legacy_status = Some(upper);
}
}
"budget_wall_clock_cap_minutes" => {
let stripped = v
.split_once('#')
.map(|(before, _)| before.trim())
.unwrap_or(v);
m.budget_wall_clock_cap_minutes = Some(stripped.parse::<u64>().map_err(|_| {
eprintln!(
"loop-check: malformed budget cap 'budget_wall_clock_cap_minutes: {v}' - failing closed; fix the config"
);
v.to_string()
}));
}
"budget_cost_cap_usd" => {
let stripped = v
.split_once('#')
.map(|(before, _)| before.trim())
.unwrap_or(v);
m.budget_cost_cap_usd = Some(stripped.parse::<f64>().map_err(|_| {
eprintln!(
"loop-check: malformed budget cap 'budget_cost_cap_usd: {v}' - failing closed; fix the config"
);
v.to_string()
}));
}
_ => {}
}
}
}
Some(m)
}
#[derive(Debug, Default)]
struct Settings {
attended_wall_cap_minutes: Option<Result<u64, String>>,
attended_cost_cap_usd: Option<Result<f64, String>>,
unattended_wall_cap_minutes: Option<Result<u64, String>>,
unattended_cost_cap_usd: Option<Result<f64, String>>,
flat_budget_cap: Option<Result<f64, String>>,
ci_declared_none: bool,
external_reviewers: Vec<String>,
github_apps: Option<Vec<String>>,
required_bots: Option<Vec<String>>,
peers: Vec<PeerEntry>,
peer_identity: Option<String>,
optional_apps: Option<Vec<String>>,
reviewers: Vec<String>,
nudge_overrides: Vec<NudgeOverride>,
done_probes: Option<Result<Vec<String>, String>>,
}
fn normalize_reviewer(raw: &str) -> String {
raw.trim().trim_start_matches('/').to_string()
}
const MALFORMED_REVIEWERS_SENTINEL: &str = "\u{0}malformed-reviewers";
#[derive(Debug, Default, Clone)]
struct PeerEntry {
provider: String,
model: Option<String>,
identity: Option<String>,
}
fn strip_inline_comment(raw: &str) -> &str {
if raw.starts_with('#') {
return "";
}
match raw.find(" #").or_else(|| raw.find("\t#")) {
Some(i) => raw[..i].trim_end(),
None => raw,
}
}
const UNPARSEABLE_SETTINGS_SENTINEL: &str = "\u{0}unparseable-settings\u{0}";
fn scalar_as_singleton(rest: &str) -> Option<Vec<String>> {
let v = strip_inline_comment(rest.trim())
.trim_matches(|c| c == '"' || c == '\'')
.to_string();
if v.is_empty() || v.contains('{') || v.contains('}') {
None
} else {
Some(vec![v])
}
}
fn scalar_string(v: &toml::Value) -> Option<String> {
match v {
toml::Value::String(s) => Some(s.clone()),
toml::Value::Boolean(b) => Some(b.to_string()),
toml::Value::Integer(n) => Some(n.to_string()),
toml::Value::Float(f) => Some(f.to_string()),
_ => None,
}
}
fn value_as_login_list(v: &toml::Value) -> Option<Vec<String>> {
match v {
toml::Value::Array(items) => Some(items.iter().filter_map(scalar_string).collect()),
toml::Value::String(_)
| toml::Value::Boolean(_)
| toml::Value::Integer(_)
| toml::Value::Float(_) => scalar_string(v).and_then(|s| scalar_as_singleton(&s)),
_ => None,
}
}
#[derive(Debug, Clone, Default)]
struct NudgeOverride {
login: String,
review_handle: Option<String>,
wait_minutes: Option<i64>,
ceiling: Option<usize>,
enabled: bool,
malformed: bool,
}
fn value_as_nudge_overrides(v: &toml::Value) -> Vec<NudgeOverride> {
let Some(table) = v.as_table() else {
return Vec::new();
};
let mut out = Vec::new();
for (login, entry) in table {
let mut ov = NudgeOverride {
login: login.clone(),
enabled: true,
..Default::default()
};
let Some(map) = entry.as_table() else {
ov.malformed = true;
out.push(ov);
continue;
};
if let Some(rh) = map.get("review_handle") {
match rh.as_str() {
Some(s) => ov.review_handle = Some(s.to_string()),
None => ov.malformed = true,
}
}
if let Some(wm) = map.get("wait_minutes") {
match wm.as_integer() {
Some(n) if (1..=MAX_NUDGE_WAIT_MINUTES).contains(&n) => ov.wait_minutes = Some(n),
_ => ov.malformed = true, }
}
if let Some(c) = map.get("ceiling") {
match c.as_integer() {
Some(n) if (1..=MAX_NUDGE_CEILING).contains(&n) => ov.ceiling = Some(n as usize),
_ => ov.malformed = true,
}
}
if let Some(en) = map.get("enabled") {
match en.as_bool() {
Some(b) => ov.enabled = b,
None => ov.malformed = true,
}
}
out.push(ov);
}
out
}
fn value_as_reviewers(v: &toml::Value) -> Vec<String> {
match v {
toml::Value::Array(items) => {
let mut out = Vec::new();
for it in items {
match scalar_string(it) {
Some(s) => {
let n = normalize_reviewer(&s);
if !n.is_empty() {
out.push(n);
}
}
None => return vec![MALFORMED_REVIEWERS_SENTINEL.to_string()],
}
}
out
}
toml::Value::String(s) => {
let n = normalize_reviewer(s);
if n.is_empty() {
Vec::new()
} else {
vec![n]
}
}
_ => vec![MALFORMED_REVIEWERS_SENTINEL.to_string()],
}
}
fn value_as_peers(v: &toml::Value) -> Vec<PeerEntry> {
let scalar_entry = |s: String| PeerEntry {
provider: s,
model: None,
identity: None,
};
let map_entry = |it: &toml::Value| -> Option<PeerEntry> {
let provider = it
.get("provider")
.and_then(scalar_string)
.unwrap_or_default();
let model = it
.get("model")
.and_then(scalar_string)
.filter(|s| !s.is_empty());
let identity = it
.get("identity")
.and_then(scalar_string)
.filter(|s| !s.is_empty());
if provider.is_empty() && identity.is_none() {
None
} else {
Some(PeerEntry {
provider,
model,
identity,
})
}
};
match v {
toml::Value::Array(items) => items
.iter()
.filter_map(|it| match it {
toml::Value::Table(_) => map_entry(it),
_ => scalar_string(it)
.filter(|s| !s.is_empty())
.map(scalar_entry),
})
.collect(),
toml::Value::String(s) if !s.is_empty() => vec![scalar_entry(s.clone())],
toml::Value::Table(_) => map_entry(v).into_iter().collect(),
_ => Vec::new(),
}
}
fn read_f64_cap(v: &toml::Value, ctx: &str) -> Option<Result<f64, String>> {
match v {
toml::Value::Integer(n) => Some(Ok(*n as f64)),
toml::Value::Float(f) => Some(Ok(*f)),
other => {
let raw = scalar_string(other).unwrap_or_default();
Some(raw.parse::<f64>().map_err(|_| {
eprintln!(
"loop-check: malformed budget cap '{ctx}: {raw}' - failing closed; fix the config"
);
raw
}))
}
}
}
fn read_u64_cap(v: &toml::Value, ctx: &str) -> Option<Result<u64, String>> {
match v {
toml::Value::Integer(n) => Some(u64::try_from(*n).map_err(|_| {
eprintln!(
"loop-check: malformed budget cap '{ctx}: {n}' - failing closed; fix the config"
);
n.to_string()
})),
other => {
let raw = scalar_string(other).unwrap_or_default();
Some(raw.parse::<u64>().map_err(|_| {
eprintln!(
"loop-check: malformed budget cap '{ctx}: {raw}' - failing closed; fix the config"
);
raw
}))
}
}
}
fn value_as_probe_list(v: &toml::Value) -> Result<Vec<String>, String> {
let items = v
.as_array()
.ok_or_else(|| format!("it is a {}, not an array of strings", v.type_str()))?;
items
.iter()
.map(|i| {
i.as_str().map(str::to_string).ok_or_else(|| {
format!(
"it holds a {} where a command string was expected",
i.type_str()
)
})
})
.collect()
}
fn fail_closed_settings() -> Settings {
let sentinel = Some(vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]);
Settings {
github_apps: sentinel.clone(),
required_bots: sentinel,
..Default::default()
}
}
fn parse_settings_result(content: &str) -> Result<Settings, String> {
let root: toml::Value = content.parse::<toml::Value>().map_err(|e| e.to_string())?;
let mut s = Settings::default();
if let Some(v) = root.get("budget_cap") {
s.flat_budget_cap = read_f64_cap(v, "budget_cap");
}
if let Some(v) = root.get("done_probes") {
s.done_probes = Some(value_as_probe_list(v));
}
if let Some(budget) = root.get("budget") {
if let Some(att) = budget.get("attended") {
if let Some(v) = att.get("wall_clock_cap_minutes") {
s.attended_wall_cap_minutes = read_u64_cap(v, "attended.wall_clock_cap_minutes");
}
if let Some(v) = att.get("cost_cap_usd") {
s.attended_cost_cap_usd = read_f64_cap(v, "attended.cost_cap_usd");
}
}
if let Some(un) = budget.get("unattended") {
if let Some(v) = un.get("wall_clock_cap_minutes") {
s.unattended_wall_cap_minutes =
read_u64_cap(v, "unattended.wall_clock_cap_minutes");
}
if let Some(v) = un.get("cost_cap_usd") {
s.unattended_cost_cap_usd = read_f64_cap(v, "unattended.cost_cap_usd");
}
}
}
if let Some(ci) = root.get("ci") {
s.ci_declared_none = ci
.get("declared_none")
.and_then(|v| v.as_bool())
.unwrap_or(false);
}
if let Some(er) = root.get("external_reviewers") {
if let Some(items) = er.as_array() {
s.external_reviewers = items.iter().filter_map(scalar_string).collect();
}
}
if let Some(review) = root.get("review") {
if let Some(v) = review.get("required_bots") {
s.required_bots = value_as_login_list(v);
}
if let Some(v) = review.get("github_apps") {
s.github_apps = value_as_login_list(v);
}
if let Some(v) = review.get("optional_apps") {
s.optional_apps = value_as_login_list(v);
}
if let Some(v) = review.get("reviewers") {
s.reviewers = value_as_reviewers(v);
}
if let Some(v) = review.get("nudge") {
s.nudge_overrides = value_as_nudge_overrides(v);
}
if let Some(v) = review.get("peers") {
s.peers = value_as_peers(v);
}
if let Some(v) = review.get("peer_identity") {
s.peer_identity = scalar_string(v).filter(|s| !s.is_empty());
}
}
Ok(s)
}
#[cfg(test)]
fn parse_settings(content: &str) -> Settings {
parse_settings_result(content).unwrap_or_else(|_| fail_closed_settings())
}
fn session_cost_from_ledger(ledger_path: &Path, session_id: &str) -> f64 {
let Ok(content) = std::fs::read_to_string(ledger_path) else {
return 0.0;
};
let Ok(arr) = serde_json::from_str::<Value>(&content) else {
return 0.0;
};
let Some(entries) = arr.as_array() else {
return 0.0;
};
let mut total = 0.0_f64;
for entry in entries {
let matches = entry.get("fno_id").and_then(|v| v.as_str()) == Some(session_id)
|| entry.get("session_id").and_then(|v| v.as_str()) == Some(session_id);
if matches {
if let Some(c) = entry.get("cost_usd").and_then(|v| v.as_f64()) {
total += c;
}
}
}
total
}
#[derive(Debug, PartialEq)]
enum Intent {
Promise,
Aborted {
reason: String,
},
Watching {
reason: String,
pr: Option<String>,
timeout: Option<String>,
},
None,
}
fn extract_assistant_text(val: &Value) -> String {
if let Some(s) = val.pointer("/message/content").and_then(|v| v.as_str()) {
return s.to_string();
}
if let Some(arr) = val.pointer("/message/content").and_then(|v| v.as_array()) {
let mut parts = Vec::new();
for block in arr {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
parts.push(t.to_string());
}
}
}
return parts.join(" ");
}
if let Some(s) = val.get("content").and_then(|v| v.as_str()) {
return s.to_string();
}
String::new()
}
fn detect_intent_from_text(text: &str) -> Intent {
if let Some(aborted_start) = text.find("<aborted") {
if let Some(gt) = text[aborted_start..].find('>') {
let tag_text = &text[aborted_start..aborted_start + gt + 1];
let reason = parse_xml_attr(tag_text, "reason").unwrap_or_default();
return Intent::Aborted { reason };
}
}
if let Some(w_start) = text.find("<watching") {
if let Some(gt) = text[w_start..].find('>') {
let tag_text = &text[w_start..w_start + gt + 1];
return Intent::Watching {
reason: parse_xml_attr(tag_text, "reason").unwrap_or_default(),
pr: parse_xml_attr(tag_text, "pr"),
timeout: parse_xml_attr(tag_text, "timeout"),
};
}
}
if text.contains("<promise>") {
return Intent::Promise;
}
Intent::None
}
fn parse_xml_attr(tag_text: &str, attr: &str) -> Option<String> {
let pattern = format!(r#"{attr}=""#);
let start = tag_text.find(&pattern)? + pattern.len();
let end = tag_text[start..].find('"')?;
Some(tag_text[start..start + end].to_string())
}
fn extract_last_assistant_message(hook_input: &str) -> Option<String> {
let val: Value = serde_json::from_str(hook_input).ok()?;
let s = val.get("last_assistant_message")?.as_str()?;
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn detect_intent(
last_assistant_message: Option<&str>,
transcript_path: &Path,
) -> (Intent, &'static str) {
match last_assistant_message {
Some(text) => (detect_intent_from_text(text), "payload"),
None => (detect_intent_full(transcript_path), "transcript"),
}
}
const INTENT_LOOKBACK_ENTRIES: usize = 5;
fn detect_intent_full(transcript_path: &Path) -> Intent {
let Ok(content) = std::fs::read_to_string(transcript_path) else {
return Intent::None;
};
let lines: Vec<&str> = content.lines().collect();
let mut scanned: usize = 0;
let mut newest_entry = true;
for line in lines.iter().rev() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(val) = serde_json::from_str::<Value>(line) else {
continue;
};
let role = val
.pointer("/message/role")
.or_else(|| val.get("role"))
.and_then(|v| v.as_str())
.unwrap_or("");
if role != "assistant" {
continue;
}
let text = extract_assistant_text(&val);
if text.is_empty() {
continue;
}
match detect_intent_from_text(&text) {
Intent::None => {
scanned += 1;
if scanned >= INTENT_LOOKBACK_ENTRIES {
return Intent::None;
}
}
Intent::Watching { .. } if !newest_entry => {
scanned += 1;
if scanned >= INTENT_LOOKBACK_ENTRIES {
return Intent::None;
}
}
tagged => return tagged,
}
newest_entry = false;
}
Intent::None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PrState {
Open,
Merged,
Closed,
None,
}
impl PrState {
fn from_gh_str(s: &str) -> Self {
match s {
"OPEN" => PrState::Open,
"MERGED" => PrState::Merged,
"CLOSED" => PrState::Closed,
_ => PrState::None,
}
}
fn as_str(&self) -> &'static str {
match self {
PrState::Open => "OPEN",
PrState::Merged => "MERGED",
PrState::Closed => "CLOSED",
PrState::None => "none",
}
}
fn is_open_or_merged(&self) -> bool {
matches!(self, PrState::Open | PrState::Merged)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CiConclusion {
Success,
Failure(Option<String>),
Pending,
Skipped,
None,
}
impl CiConclusion {
fn render(&self) -> String {
match self {
CiConclusion::Success => "SUCCESS".to_string(),
CiConclusion::Failure(Some(name)) => format!("FAILURE:{name}"),
CiConclusion::Failure(None) => "FAILURE".to_string(),
CiConclusion::Pending => "PENDING".to_string(),
CiConclusion::Skipped => "skipped".to_string(),
CiConclusion::None => "none".to_string(),
}
}
fn is_ok(&self) -> bool {
matches!(self, CiConclusion::Success | CiConclusion::Skipped)
}
}
#[derive(Debug)]
struct PrInfo {
state: PrState,
number: i64,
head_oid: String,
ci_conclusion: CiConclusion,
failing_checks: Vec<String>,
ci_has_pending: bool,
mergeable: String,
latest_review_ts: String,
reviewed: bool, missing_bots: Vec<String>,
bot_nudges: Vec<BotNudge>,
usage_limited: Vec<String>,
unaddressed_findings: Vec<Finding>,
review_skipped: bool,
unattested_reviewers: Vec<UnattestedReviewer>,
malformed_attestations: usize,
}
const REVIEWER_INVOCATIONS: &[(&str, &str, bool)] = &[
("sigma", "/fno:review sigma", false),
(
"code-review",
"/code-review, then bash skills/review/scripts/emit-attestation.sh code-review",
false,
),
("declare", "/fno:review declare", true),
];
fn reviewer_invocation(name: &str) -> Option<(&'static str, bool)> {
REVIEWER_INVOCATIONS
.iter()
.find(|(n, _, _)| *n == name)
.map(|(_, inv, self_cert)| (*inv, *self_cert))
}
fn git_head_sha(git_bin: &str, cwd: &Path) -> String {
let out = Command::new(git_bin)
.args(["rev-parse", "HEAD"])
.current_dir(cwd)
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "unknown".to_string(),
}
}
fn is_no_pr_stderr(stderr: &[u8]) -> bool {
String::from_utf8_lossy(stderr)
.to_lowercase()
.contains("no pull requests found")
}
fn stderr_tail(bytes: &[u8]) -> String {
let s = String::from_utf8_lossy(bytes);
let s = s.trim();
if s.len() <= 200 {
s.to_string()
} else {
let mut start = s.len() - 200;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_string()
}
}
#[derive(Debug, Clone, PartialEq)]
struct UnattestedReviewer {
name: String,
superseded_head: Option<String>,
failed_at_head: bool,
}
fn unattested_reviewers_scan(
events_path: &Path,
reviewers: &[String],
head_sha: &str,
) -> (Vec<UnattestedReviewer>, usize) {
let unsatisfied_all = || -> Vec<UnattestedReviewer> {
reviewers
.iter()
.map(|r| UnattestedReviewer {
name: r.trim_start_matches('/').to_string(),
superseded_head: None,
failed_at_head: false,
})
.collect()
};
if reviewers.is_empty() {
return (Vec::new(), 0);
}
let Ok(content) = std::fs::read_to_string(events_path) else {
return (unsatisfied_all(), 0);
};
let mut malformed = 0usize;
let mut latest_pass: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
let mut other_heads: std::collections::HashMap<String, Vec<(String, bool)>> =
std::collections::HashMap::new();
for line in content.lines() {
let Ok(val) = serde_json::from_str::<Value>(line) else {
if line.contains("review_attestation") {
malformed += 1;
}
continue;
};
if val.get("type").and_then(|v| v.as_str()) != Some("review_attestation") {
continue;
}
let Some(r) = val.pointer("/data/reviewer").and_then(|v| v.as_str()) else {
continue;
};
let r = r.trim_start_matches('/').to_string();
let Some(line_head) = val.pointer("/data/head_sha").and_then(|v| v.as_str()) else {
continue;
};
let is_pass = val.pointer("/data/verdict").and_then(|v| v.as_str()) == Some("pass");
if line_head != head_sha {
if line_head.is_empty() {
continue;
}
let seen = other_heads.entry(r).or_default();
match seen.iter().position(|(h, _)| h == line_head) {
Some(i) => seen[i].1 = is_pass, None => seen.push((line_head.to_string(), is_pass)),
}
continue;
}
latest_pass.insert(r, is_pass);
}
let out = reviewers
.iter()
.map(|entry| entry.trim_start_matches('/'))
.filter(|name| latest_pass.get(*name) != Some(&true))
.map(|name| UnattestedReviewer {
name: name.to_string(),
superseded_head: other_heads
.get(name)
.and_then(|heads| heads.iter().rev().find(|(_, ok)| *ok))
.map(|(h, _)| h.clone()),
failed_at_head: latest_pass.get(name) == Some(&false),
})
.collect();
(out, malformed)
}
#[derive(Debug, Clone)]
struct OpenFinding {
id: String,
first_line: String,
}
fn open_review_findings(events_path: &Path, node: &str) -> (Vec<OpenFinding>, usize) {
let Ok(content) = std::fs::read_to_string(events_path) else {
return (Vec::new(), 0);
};
let mut findings: Vec<(String, String)> = Vec::new();
let mut resolved: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut malformed = 0usize;
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(val) = serde_json::from_str::<Value>(line) else {
if line.contains("review_finding") {
malformed += 1;
}
continue;
};
match val.get("type").and_then(|v| v.as_str()) {
Some("review_finding") => {
if val.pointer("/data/node").and_then(|v| v.as_str()) != Some(node) {
continue;
}
match val.pointer("/data/finding_id").and_then(|v| v.as_str()) {
Some(id) => {
let first = val
.pointer("/data/text")
.and_then(|v| v.as_str())
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string();
if let Some(slot) = findings.iter_mut().find(|(fid, _)| fid == id) {
slot.1 = first;
} else {
findings.push((id.to_string(), first));
}
}
None => malformed += 1, }
}
Some("review_finding_resolved") => {
if let Some(id) = val.pointer("/data/finding_id").and_then(|v| v.as_str()) {
resolved.insert(id.to_string());
}
}
_ => {}
}
}
let mut open: Vec<OpenFinding> = findings
.into_iter()
.filter(|(id, _)| !resolved.contains(id))
.map(|(id, first_line)| OpenFinding { id, first_line })
.collect();
open.sort_by(|a, b| a.id.cmp(&b.id)); (open, malformed)
}
fn build_findings_block_reason(open: &[OpenFinding], malformed: usize) -> String {
let f = &open[0];
let more = if open.len() > 1 {
format!(" [+{} more]", open.len() - 1)
} else {
String::new()
};
let notice = if malformed > 0 {
format!(" ({malformed} malformed finding line(s) ignored)")
} else {
String::new()
};
format!(
"open review finding {}: {} - address it, then `fno annotate resolve {}`{}{}",
f.id, f.first_line, f.id, more, notice
)
}
#[allow(clippy::too_many_arguments)]
fn read_pr_info(
gh_bin: &str,
cwd: &Path,
ci_declared_none: bool,
no_external: bool,
required_bots: &[String],
optional_bots: &[String],
external_reviewers: &[String],
reviewers: &[String],
nudge_configs: &[NudgeConfig],
head_sha: &str,
events_path: &Path,
) -> Result<PrInfo, (String, String)> {
let pr_view_out = Command::new(gh_bin)
.args([
"pr",
"view",
"--json",
"state,number,headRefName,headRefOid,mergeable",
])
.current_dir(cwd)
.output()
.map_err(|e| ("pr_view".to_string(), e.to_string()))?;
if !pr_view_out.status.success() {
if is_no_pr_stderr(&pr_view_out.stderr) {
return Ok(PrInfo {
state: PrState::None,
number: 0,
head_oid: String::new(),
ci_conclusion: CiConclusion::None,
failing_checks: Vec::new(),
ci_has_pending: false,
mergeable: "UNKNOWN".to_string(),
latest_review_ts: "none".to_string(),
reviewed: false,
missing_bots: Vec::new(),
bot_nudges: Vec::new(),
usage_limited: Vec::new(),
unaddressed_findings: Vec::new(),
review_skipped: false,
unattested_reviewers: Vec::new(),
malformed_attestations: 0,
});
}
return Err(("pr_view".to_string(), stderr_tail(&pr_view_out.stderr)));
}
let pr_json: Value = serde_json::from_slice(&pr_view_out.stdout)
.map_err(|_| ("pr_view_parse".to_string(), String::new()))?;
let state = PrState::from_gh_str(
pr_json
.get("state")
.and_then(|v| v.as_str())
.unwrap_or("none"),
);
let number = pr_json.get("number").and_then(|v| v.as_i64()).unwrap_or(0);
let head_oid = pr_json
.get("headRefOid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mergeable = pr_json
.get("mergeable")
.and_then(|v| v.as_str())
.unwrap_or("UNKNOWN")
.to_string();
if state == PrState::Merged {
return Ok(PrInfo {
state,
number,
head_oid,
ci_conclusion: CiConclusion::Skipped,
failing_checks: Vec::new(),
ci_has_pending: false,
mergeable,
latest_review_ts: "none".to_string(),
reviewed: true,
missing_bots: Vec::new(),
bot_nudges: Vec::new(),
usage_limited: Vec::new(),
unaddressed_findings: Vec::new(),
review_skipped: true,
unattested_reviewers: Vec::new(),
malformed_attestations: 0,
});
}
let no_hosted_ci =
crate::verify_evidence::hosted_ci_not_configured(ci_declared_none, cwd, head_sha);
let (ci_conclusion, failing_checks, ci_has_pending) = if no_hosted_ci {
(CiConclusion::Skipped, Vec::new(), false)
} else {
let checks_out = Command::new(gh_bin)
.args(["pr", "checks", "--json", "name,state,bucket"])
.current_dir(cwd)
.output()
.map_err(|e| ("pr_checks".to_string(), e.to_string()))?;
if !checks_out.status.success() {
return Err(("pr_checks".to_string(), stderr_tail(&checks_out.stderr)));
}
let checks: Value = serde_json::from_slice(&checks_out.stdout)
.map_err(|_| ("pr_checks_parse".to_string(), String::new()))?;
let failing = failing_check_names(&checks);
let has_pending = ci_has_pending_checks(&checks);
(
compute_ci_conclusion(&checks).map_err(|e| (e, String::new()))?,
failing,
has_pending,
)
};
let login_gate_active = !required_bots.is_empty() || !optional_bots.is_empty();
let login_skipped = no_external || !login_gate_active;
let (unattested, malformed_attestations) =
unattested_reviewers_scan(events_path, reviewers, head_sha);
let reviewers_ok = unattested.is_empty();
let (latest_review_ts, reviewed, missing_bots, bot_nudges, usage_limited, unaddressed_findings) =
if login_skipped {
(
"none".to_string(),
reviewers_ok,
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
)
} else {
let reviews_out = Command::new(gh_bin)
.args(["pr", "view", "--json", "reviews,comments"])
.current_dir(cwd)
.output()
.map_err(|e| ("pr_reviews".to_string(), e.to_string()))?;
if !reviews_out.status.success() {
return Err(("pr_reviews".to_string(), stderr_tail(&reviews_out.stderr)));
}
let reviews_json: Value = serde_json::from_slice(&reviews_out.stdout)
.map_err(|_| ("pr_reviews_parse".to_string(), String::new()))?;
let info = compute_review_info(&reviews_json, required_bots);
let now = Utc::now();
let review_comments = reviews_json
.get("comments")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or(&[]);
let bot_nudges: Vec<BotNudge> = info
.missing_bots
.iter()
.map(|bot| {
classify_bot_nudge(
bot,
review_comments,
nudge_config_for(nudge_configs, bot),
now,
)
})
.collect();
debug_assert_eq!(bot_nudges.len(), info.missing_bots.len());
let mut findings_bots: Vec<String> = required_bots.to_vec();
for b in optional_bots {
if !findings_bots.iter().any(|x| x == b) {
findings_bots.push(b.clone());
}
}
let comments_out = Command::new(gh_bin)
.args([
"api",
&format!("repos/{{owner}}/{{repo}}/pulls/{number}/comments"),
"--paginate",
])
.current_dir(cwd)
.output()
.map_err(|e| ("pulls_comments".to_string(), e.to_string()))?;
if !comments_out.status.success() {
return Err((
"pulls_comments".to_string(),
stderr_tail(&comments_out.stderr),
));
}
let mut inline_comments: Vec<Value> = Vec::new();
for page in
serde_json::Deserializer::from_slice(&comments_out.stdout).into_iter::<Value>()
{
let page = page.map_err(|_| ("pulls_comments_parse".to_string(), String::new()))?;
match page.as_array() {
Some(arr) => inline_comments.extend(arr.iter().cloned()),
None => return Err(("pulls_comments_parse".to_string(), String::new())),
}
}
let has_blocking_candidate = inline_comments.iter().any(|c| {
c.get("in_reply_to_id").and_then(|v| v.as_i64()).is_none()
&& blocking_severity(c.get("body").and_then(|v| v.as_str()).unwrap_or(""))
.is_some()
});
let commit_dates: Vec<String> = if has_blocking_candidate {
let commits_out = Command::new(gh_bin)
.args(["pr", "view", "--json", "commits"])
.current_dir(cwd)
.output()
.map_err(|e| ("pr_commits".to_string(), e.to_string()))?;
if !commits_out.status.success() {
return Err(("pr_commits".to_string(), stderr_tail(&commits_out.stderr)));
}
let commits_json: Value = serde_json::from_slice(&commits_out.stdout)
.map_err(|_| ("pr_commits_parse".to_string(), String::new()))?;
commits_json
.get("commits")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| {
c.get("committedDate")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let (inline_ts, unaddressed) = compute_unaddressed_findings(
&inline_comments,
&commit_dates,
&findings_bots,
external_reviewers,
);
let activity_ts = max_ts(&info.latest_ts, &inline_ts);
let reviewed = info.all_required_passed() && unaddressed.is_empty() && reviewers_ok;
if !info.usage_limited.is_empty() {
append_loop_event(
events_path,
"review_gate_bot_usage_limited",
serde_json::json!({"pr": number, "bots": info.usage_limited.clone()}),
);
}
(
activity_ts,
reviewed,
info.missing_bots,
bot_nudges,
info.usage_limited,
unaddressed,
)
};
Ok(PrInfo {
state,
number,
head_oid,
ci_conclusion,
failing_checks,
ci_has_pending,
mergeable,
latest_review_ts,
reviewed,
missing_bots,
bot_nudges,
usage_limited,
unaddressed_findings,
review_skipped: login_skipped && reviewers.is_empty(),
unattested_reviewers: unattested,
malformed_attestations,
})
}
fn compute_ci_conclusion(checks: &Value) -> Result<CiConclusion, String> {
let arr = match checks.as_array() {
Some(a) => a,
None => return Err("pr_checks_parse".to_string()),
};
if arr.is_empty() {
return Ok(CiConclusion::None);
}
let bucket_of = |check: &Value| -> String {
check
.get("bucket")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase()
};
if let Some(failing) = arr
.iter()
.find(|c| matches!(bucket_of(c).as_str(), "fail" | "cancel"))
{
let name = failing
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
return Ok(CiConclusion::Failure(Some(name.to_string())));
}
if arr
.iter()
.any(|c| !matches!(bucket_of(c).as_str(), "pass" | "skipping"))
{
return Ok(CiConclusion::Pending);
}
Ok(CiConclusion::Success)
}
const MAIN_RUN_LOOKBACK: usize = 10;
fn failing_check_names(checks: &Value) -> Vec<String> {
let Some(arr) = checks.as_array() else {
return Vec::new();
};
arr.iter()
.filter(|c| {
let bucket = c
.get("bucket")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase();
matches!(bucket.as_str(), "fail" | "cancel")
})
.filter_map(|c| c.get("name").and_then(|v| v.as_str()).map(str::to_string))
.collect()
}
fn ci_has_pending_checks(checks: &Value) -> bool {
let Some(arr) = checks.as_array() else {
return false;
};
arr.iter().any(|c| {
let bucket = c
.get("bucket")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase();
!matches!(bucket.as_str(), "pass" | "fail" | "cancel" | "skipping")
})
}
fn parse_failing_run_ids(run_list: &Value, head_sha: &str) -> Vec<i64> {
let Some(arr) = run_list.as_array() else {
return Vec::new();
};
arr.iter()
.filter(|r| r.get("conclusion").and_then(|v| v.as_str()) == Some("failure"))
.filter(|r| r.get("headSha").and_then(|v| v.as_str()) == Some(head_sha))
.filter_map(|r| r.get("databaseId").and_then(|v| v.as_i64()))
.collect()
}
fn parse_failing_job_names(jobs_json: &Value) -> Vec<String> {
let Some(jobs) = jobs_json.get("jobs").and_then(|v| v.as_array()) else {
return Vec::new();
};
jobs.iter()
.filter(|j| j.get("conclusion").and_then(|v| v.as_str()) == Some("failure"))
.filter_map(|j| j.get("name").and_then(|v| v.as_str()).map(str::to_string))
.collect()
}
fn is_pre_existing_main_red(pr_failing: &[String], main_failing: &[String]) -> bool {
if pr_failing.is_empty() {
return false;
}
pr_failing.iter().all(|c| main_failing.contains(c))
}
fn main_head_failing_checks(gh_bin: &str, cwd: &Path, n: usize) -> Option<Vec<String>> {
let list_out = Command::new(gh_bin)
.args([
"run",
"list",
"--branch",
"main",
"--status",
"completed",
"--limit",
&n.to_string(),
"--json",
"databaseId,conclusion,headSha",
])
.current_dir(cwd)
.output()
.ok()?;
if !list_out.status.success() {
return None; }
let list: Value = serde_json::from_slice(&list_out.stdout).ok()?;
let arr = list.as_array()?;
let head_sha = arr
.first()
.and_then(|r| r.get("headSha"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
let failing_run_ids = parse_failing_run_ids(&list, head_sha);
let mut names: Vec<String> = Vec::new();
for id in failing_run_ids {
let view_out = Command::new(gh_bin)
.args(["run", "view", &id.to_string(), "--json", "jobs"])
.current_dir(cwd)
.output()
.ok()?;
if !view_out.status.success() {
return None; }
let view: Value = serde_json::from_slice(&view_out.stdout).ok()?;
for name in parse_failing_job_names(&view) {
if !names.contains(&name) {
names.push(name);
}
}
}
Some(names)
}
fn already_emitted_awaiting_merge(events_path: &Path, session_id: &str) -> bool {
let Ok(content) = std::fs::read_to_string(events_path) else {
return false;
};
content.lines().any(|line| {
let Ok(val) = serde_json::from_str::<Value>(line) else {
return false;
};
val.get("type").and_then(|v| v.as_str()) == Some("termination")
&& val.pointer("/data/session_id").and_then(|v| v.as_str()) == Some(session_id)
&& val.pointer("/data/reason").and_then(|v| v.as_str()) == Some("DoneAwaitingMerge")
})
}
fn best_effort_notify(title: &str, body: &str) {
if std::env::var("FNO_LOOPCHECK_NO_NOTIFY").as_deref() == Ok("1") {
return;
}
let fno_bin = std::env::var_os("FNO_LOOPCHECK_FNO_BIN").unwrap_or_else(|| "fno".into());
let _ = Command::new(fno_bin).args(["notify", title, body]).spawn();
}
fn post_nudge_comment(gh_bin: &str, cwd: &Path, pr_number: i64, review_handle: &str) -> bool {
if std::env::var("FNO_LOOPCHECK_NO_COMMENT").as_deref() == Ok("1") {
return false;
}
Command::new(gh_bin)
.args([
"pr",
"comment",
&pr_number.to_string(),
"--body",
review_handle,
])
.current_dir(cwd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn unresponsive_bot(pr: &PrInfo) -> Option<&BotNudge> {
pr.bot_nudges
.iter()
.find(|n| n.class == NudgeClass::Unresponsive)
}
fn nudge_giveup_message(n: &BotNudge) -> String {
format!(
"{} did not review after {} nudges over {}m; giving up (NoProgress). \
Move it to config.review.optional_apps or review by hand.",
n.login, n.nudges, n.span_min
)
}
struct BotProfile {
login: &'static str,
review_handle: &'static str,
reply_handle: &'static str,
usage_markers: &'static [&'static str],
nudgeable: bool,
}
const BOT_PROFILES: &[BotProfile] = &[
BotProfile {
login: "chatgpt-codex-connector",
review_handle: "@codex review",
reply_handle: "@chatgpt-codex-connector",
usage_markers: &["usage limits for code reviews", "codex usage limits"],
nudgeable: true,
},
BotProfile {
login: "gemini-code-assist",
review_handle: "",
reply_handle: "@gemini-code-assist",
usage_markers: &[],
nudgeable: false,
},
];
fn profile_by_author(author: &str) -> Option<&'static BotProfile> {
BOT_PROFILES
.iter()
.find(|p| login_matches_bot(author, p.login))
}
fn logins_correspond(a: &str, b: &str) -> bool {
login_matches_bot(a, b) || login_matches_bot(b, a)
}
const DEFAULT_NUDGE_WAIT_MINUTES: i64 = 15;
const DEFAULT_NUDGE_CEILING: usize = 3;
const MAX_NUDGE_WAIT_MINUTES: i64 = 7 * 24 * 60; const MAX_NUDGE_CEILING: i64 = 1000;
#[derive(Debug, Clone)]
struct NudgeConfig {
login: String,
review_handle: String,
wait_minutes: i64,
ceiling: usize,
}
fn resolved_nudge_configs(settings: &Settings) -> Vec<NudgeConfig> {
let mut out: Vec<NudgeConfig> = BOT_PROFILES
.iter()
.filter(|p| p.nudgeable && !p.review_handle.is_empty())
.map(|p| NudgeConfig {
login: p.login.to_string(),
review_handle: p.review_handle.to_string(),
wait_minutes: DEFAULT_NUDGE_WAIT_MINUTES,
ceiling: DEFAULT_NUDGE_CEILING,
})
.collect();
for ov in &settings.nudge_overrides {
let base = out
.iter()
.find(|c| logins_correspond(&c.login, &ov.login))
.cloned();
out.retain(|c| !logins_correspond(&c.login, &ov.login));
if ov.malformed || !ov.enabled {
continue; }
let handle = ov
.review_handle
.clone()
.or_else(|| base.as_ref().map(|b| b.review_handle.clone()))
.filter(|h| !h.is_empty());
let Some(review_handle) = handle else {
continue; };
out.push(NudgeConfig {
login: ov.login.clone(),
review_handle,
wait_minutes: ov
.wait_minutes
.or_else(|| base.as_ref().map(|b| b.wait_minutes))
.unwrap_or(DEFAULT_NUDGE_WAIT_MINUTES),
ceiling: ov
.ceiling
.or_else(|| base.as_ref().map(|b| b.ceiling))
.unwrap_or(DEFAULT_NUDGE_CEILING),
});
}
out
}
fn nudge_config_for<'a>(configs: &'a [NudgeConfig], bot: &str) -> Option<&'a NudgeConfig> {
configs.iter().find(|c| logins_correspond(&c.login, bot))
}
#[derive(Debug, Clone, PartialEq)]
enum NudgeClass {
NeedsNudge,
Awaiting,
Unresponsive,
NotNudgeable,
}
#[derive(Debug, Clone)]
struct BotNudge {
login: String,
class: NudgeClass,
review_handle: String,
ceiling: usize,
nudges: usize,
newest_age_min: i64,
span_min: i64,
}
impl BotNudge {
fn not_nudgeable(login: &str) -> Self {
BotNudge {
login: login.to_string(),
class: NudgeClass::NotNudgeable,
review_handle: String::new(),
ceiling: 0,
nudges: 0,
newest_age_min: 0,
span_min: 0,
}
}
}
fn nudge_class_idlable(class: &NudgeClass) -> bool {
matches!(class, NudgeClass::Awaiting | NudgeClass::NotNudgeable)
}
fn classify_bot_nudge(
login: &str,
comments: &[Value],
cfg: Option<&NudgeConfig>,
now: DateTime<Utc>,
) -> BotNudge {
let Some(cfg) = cfg else {
return BotNudge::not_nudgeable(login);
};
if cfg.review_handle.is_empty() {
return BotNudge::not_nudgeable(login);
}
let mut total = 0usize;
let mut times: Vec<DateTime<Utc>> = Vec::new();
for c in comments {
let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
if !body.contains(&cfg.review_handle) {
continue;
}
total += 1;
if let Some(dt) = c
.get("createdAt")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
{
times.push(dt);
}
}
if total == 0 {
return BotNudge {
login: login.to_string(),
class: NudgeClass::NeedsNudge,
review_handle: cfg.review_handle.clone(),
ceiling: cfg.ceiling,
nudges: 0,
newest_age_min: 0,
span_min: 0,
};
}
let (Some(newest), Some(oldest)) = (times.iter().max().copied(), times.iter().min().copied())
else {
return BotNudge {
login: login.to_string(),
class: NudgeClass::NeedsNudge,
review_handle: cfg.review_handle.clone(),
ceiling: cfg.ceiling,
nudges: total,
newest_age_min: 0,
span_min: 0,
};
};
let newest_age_min = (now - newest).num_minutes().max(0);
let span_min = (now - oldest).num_minutes().max(0);
let class = if (now - newest) < chrono::Duration::minutes(cfg.wait_minutes) {
NudgeClass::Awaiting
} else if total >= cfg.ceiling {
NudgeClass::Unresponsive
} else {
NudgeClass::NeedsNudge };
BotNudge {
login: login.to_string(),
class,
review_handle: cfg.review_handle.clone(),
ceiling: cfg.ceiling,
nudges: total,
newest_age_min,
span_min,
}
}
const DEFAULT_REQUIRED_BOTS: &[&str] = &[];
const LOCAL_PEER_REVIEWER: &str = "peer";
const SAME_MODEL_LOCAL_PEER_SENTINEL: &str = "\u{0}fno-peer-same-model-local\u{0}";
const SAME_MODEL_PEER_SENTINEL: &str = "\u{0}fno-peer-same-model\u{0}";
fn harness_family(name: &str) -> Option<&'static str> {
match name.trim().to_ascii_lowercase().as_str() {
"claude" | "anthropic" => Some("anthropic"),
"codex" | "openai" => Some("openai"),
"gemini" | "google" => Some("google"),
_ => None,
}
}
fn route_provider(model: &str) -> Option<&str> {
let mut parts = model.split(',').map(str::trim);
match (parts.next(), parts.next(), parts.next()) {
(Some(prov), Some(rest), None) if !prov.is_empty() && !rest.is_empty() => Some(prov),
_ => None,
}
}
fn peer_family(peer: &PeerEntry) -> Option<&'static str> {
let effective = peer
.model
.as_deref()
.filter(|_| peer.provider.trim().eq_ignore_ascii_case("claude"))
.and_then(route_provider)
.unwrap_or(peer.provider.as_str());
harness_family(effective)
}
#[cfg(test)]
fn resolved_required_bots(settings: &Settings) -> Vec<String> {
resolved_required_bots_for_author(settings, None)
}
fn resolved_required_bots_for_author(
settings: &Settings,
author_harness: Option<&str>,
) -> Vec<String> {
if settings.github_apps.is_some() && settings.required_bots.is_some() {
eprintln!(
"loop-check: both config.review.github_apps and required_bots set - using github_apps"
);
}
let mut logins: Vec<String> = match settings
.github_apps
.as_ref()
.or(settings.required_bots.as_ref())
{
Some(list) => list.clone(),
None => DEFAULT_REQUIRED_BOTS
.iter()
.map(|s| s.to_string())
.collect(),
};
for peer in &settings.peers {
let id = peer
.identity
.clone()
.or_else(|| settings.peer_identity.clone());
match id {
Some(id) if !logins.iter().any(|l| l == &id) => logins.push(id),
Some(_) => {} None => {} }
}
if let Some(author) = author_harness.filter(|_| !settings.peers.is_empty()) {
if let Some(author_fam) = harness_family(author) {
apply_same_model_guard(&mut logins, settings, author, author_fam);
}
}
logins
}
fn resolved_local_peer_reviewers_for_author(
settings: &Settings,
author_harness: Option<&str>,
) -> Vec<String> {
if settings.peer_identity.is_some() {
return Vec::new();
}
let local: Vec<&PeerEntry> = settings
.peers
.iter()
.filter(|peer| peer.identity.is_none())
.collect();
if local.is_empty() {
return Vec::new();
}
let Some(author_fam) = author_harness.and_then(harness_family) else {
return vec![LOCAL_PEER_REVIEWER.to_string()];
};
if local
.iter()
.any(|peer| peer_family(peer) != Some(author_fam))
{
vec![LOCAL_PEER_REVIEWER.to_string()]
} else {
eprintln!(
"loop-check: every identity-free peer is the author's own model - configure a cross-model peer or routed model"
);
vec![SAME_MODEL_LOCAL_PEER_SENTINEL.to_string()]
}
}
fn apply_same_model_guard(
logins: &mut Vec<String>,
settings: &Settings,
author_harness: &str,
author_fam: &str,
) {
let base_set = settings
.github_apps
.as_ref()
.or(settings.required_bots.as_ref());
let mut seen: Vec<(String, bool, String)> = Vec::new();
for peer in &settings.peers {
let Some(login) = peer
.identity
.as_deref()
.or(settings.peer_identity.as_deref())
else {
continue;
};
let cross = peer_family(peer) != Some(author_fam);
match seen.iter_mut().find(|(l, _, _)| l.as_str() == login) {
Some(entry) => entry.1 = entry.1 || cross,
None => seen.push((login.to_string(), cross, peer.provider.clone())),
}
}
for (login, any_cross, provider) in seen {
if any_cross {
continue;
}
if base_set.is_some_and(|set| set.contains(&login)) {
if !logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL) {
logins.push(SAME_MODEL_PEER_SENTINEL.to_string());
}
} else if let Some(slot) = logins.iter_mut().find(|l| **l == login) {
*slot = SAME_MODEL_PEER_SENTINEL.to_string();
}
eprintln!(
"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"
);
}
}
fn resolved_optional_bots(settings: &Settings) -> Vec<String> {
settings.optional_apps.clone().unwrap_or_default()
}
pub(crate) fn login_matches_bot(login: &str, bot: &str) -> bool {
!bot.is_empty() && login.to_lowercase().contains(&bot.to_lowercase())
}
fn is_bot_reviewer(login: &str, external_reviewers: &[String]) -> bool {
if !external_reviewers.is_empty() {
let login_lower = login.to_lowercase();
if external_reviewers
.iter()
.any(|r| login_lower.contains(&r.to_lowercase()))
{
return true;
}
}
login.ends_with("[bot]") || BOT_PROFILES.iter().any(|p| login.contains(p.login))
}
pub(crate) fn body_is_usage_limit(body: &str) -> bool {
BOT_PROFILES
.iter()
.flat_map(|p| p.usage_markers.iter())
.any(|m| body.contains(m))
}
#[derive(Debug)]
struct ReviewInfo {
latest_ts: String,
missing_bots: Vec<String>,
usage_limited: Vec<String>,
}
impl ReviewInfo {
fn all_required_passed(&self) -> bool {
self.missing_bots.is_empty()
}
}
fn compute_review_info(reviews_json: &Value, required_bots: &[String]) -> ReviewInfo {
let reviews = reviews_json
.get("reviews")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or(&[]);
let comments = reviews_json
.get("comments")
.and_then(|v| v.as_array())
.map(|v| v.as_slice())
.unwrap_or(&[]);
let mut latest_ts = String::new(); let mut passed: Vec<bool> = vec![false; required_bots.len()];
for r in reviews {
let login = r
.pointer("/author/login")
.and_then(|v| v.as_str())
.unwrap_or("");
let submitted_at = r.get("submittedAt").and_then(|v| v.as_str()).unwrap_or("");
let state = r.get("state").and_then(|v| v.as_str()).unwrap_or("");
if !submitted_at.is_empty() && submitted_at > latest_ts.as_str() {
latest_ts = submitted_at.to_string();
}
if !state.is_empty() {
for (i, bot) in required_bots.iter().enumerate() {
if login_matches_bot(login, bot) {
passed[i] = true;
}
}
}
}
for c in comments {
let created_at = c.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
if !created_at.is_empty() && created_at > latest_ts.as_str() {
latest_ts = created_at.to_string();
}
}
let final_ts = if latest_ts.is_empty() {
"none".to_string()
} else {
latest_ts
};
let mut missing_bots: Vec<String> = required_bots
.iter()
.zip(passed.iter())
.filter(|(_, ok)| !**ok)
.map(|(bot, _)| bot.clone())
.collect();
let mut usage_limited: Vec<String> = Vec::new();
missing_bots.retain(|bot| {
let rate_limited = comments.iter().any(|c| {
let login = c
.pointer("/author/login")
.and_then(|v| v.as_str())
.unwrap_or("");
if !login_matches_bot(login, bot) {
return false;
}
let body = c
.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase();
body_is_usage_limit(&body)
});
if rate_limited {
usage_limited.push(bot.clone());
false
} else {
true
}
});
ReviewInfo {
latest_ts: final_ts,
missing_bots,
usage_limited,
}
}
#[derive(Debug, Clone)]
struct Finding {
id: i64,
author: String,
path: String,
line: i64,
created_at: String,
severity: &'static str,
}
fn blocking_severity(body: &str) -> Option<&'static str> {
if body.contains("![P1 Badge]") || body.contains("badge/P1-") {
return Some("P1");
}
if body.contains("![critical]") || body.contains("critical-priority.svg") {
return Some("critical");
}
if body.contains("![high]") || body.contains("high-priority.svg") {
return Some("high");
}
None
}
fn max_ts(a: &str, b: &str) -> String {
if let (Ok(da), Ok(db)) = (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
return if da >= db {
a.to_string()
} else {
b.to_string()
};
}
let a_real = !a.is_empty() && a != "none";
let b_real = !b.is_empty() && b != "none";
match (a_real, b_real) {
(true, true) => {
if a >= b {
a.to_string()
} else {
b.to_string()
}
}
(true, false) => a.to_string(),
(false, true) => b.to_string(),
(false, false) => "none".to_string(),
}
}
const WONTFIX_MARKER: &str = "wontfix:";
fn ts_after(a: &str, b: &str) -> bool {
match (a.parse::<DateTime<Utc>>(), b.parse::<DateTime<Utc>>()) {
(Ok(da), Ok(db)) => da > db,
_ => false,
}
}
fn compute_unaddressed_findings(
comments: &[Value],
commit_dates: &[String],
required_bots: &[String],
external_reviewers: &[String],
) -> (String, Vec<Finding>) {
let mut latest_ts = String::new();
let mut candidates: Vec<Finding> = Vec::new();
let mut replies: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
for c in comments {
let created_at = c.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
if !created_at.is_empty() && created_at > latest_ts.as_str() {
latest_ts = created_at.to_string();
}
let login = c
.pointer("/user/login")
.and_then(|v| v.as_str())
.unwrap_or("");
let body = c.get("body").and_then(|v| v.as_str()).unwrap_or("");
let in_reply_to = c.get("in_reply_to_id").and_then(|v| v.as_i64());
match in_reply_to {
Some(parent_id) => {
if !is_bot_reviewer(login, external_reviewers) {
replies.entry(parent_id).or_default().push(body.to_string());
}
}
None => {
let by_required_bot = required_bots
.iter()
.any(|bot| login_matches_bot(login, bot));
if by_required_bot {
if let Some(severity) = blocking_severity(body) {
let Some(id) = c.get("id").and_then(|v| v.as_i64()) else {
eprintln!(
"loop-check: skipping blocking finding with missing id (author={login})"
);
continue;
};
candidates.push(Finding {
id,
author: login.to_string(),
path: c
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
line: c
.get("line")
.and_then(|v| v.as_i64())
.or_else(|| c.get("original_line").and_then(|v| v.as_i64()))
.unwrap_or(0),
created_at: created_at.to_string(),
severity,
});
}
}
}
}
}
let unaddressed: Vec<Finding> = candidates
.into_iter()
.filter(|f| {
let non_bot_replies = replies.get(&f.id);
let has_reply = non_bot_replies.map(|r| !r.is_empty()).unwrap_or(false);
if !has_reply {
return true; }
let commit_after = commit_dates.iter().any(|d| ts_after(d, &f.created_at));
let wontfix = non_bot_replies
.map(|rs| rs.iter().any(|b| b.to_lowercase().contains(WONTFIX_MARKER)))
.unwrap_or(false);
!(commit_after || wontfix)
})
.collect();
let final_ts = if latest_ts.is_empty() {
"none".to_string()
} else {
latest_ts
};
(final_ts, unaddressed)
}
fn make_fingerprint(
head_sha: &str,
pr_state: &str,
ci_conclusion: &str,
latest_ts: &str,
) -> String {
format!("{head_sha}|{pr_state}|{ci_conclusion}|{latest_ts}")
}
const MIN_FIRE_GAP_SECS: i64 = 300;
fn min_fire_gap_secs() -> i64 {
std::env::var("FNO_LOOPCHECK_MIN_FIRE_GAP_SECS")
.ok()
.and_then(|s| s.trim().parse::<i64>().ok())
.unwrap_or(MIN_FIRE_GAP_SECS)
}
fn read_prior_fires(
events_path: &Path,
session_id: &str,
current_fp: &str,
now: DateTime<Utc>,
min_gap_secs: i64,
) -> (u64, u64, Option<String>, i64) {
let Ok(content) = std::fs::read_to_string(events_path) else {
return (0, 0, None, 0);
};
let mut total: u64 = 0;
for line in content.lines() {
let Ok(val) = serde_json::from_str::<Value>(line) else {
continue;
};
if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
continue;
}
if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
continue;
}
total += 1;
}
let mut consecutive: u64 = 0;
let mut last_fp: Option<String> = None;
let mut next_ts = now;
let mut oldest_counted_ts: Option<DateTime<Utc>> = None;
for line in content.lines().rev() {
let Ok(val) = serde_json::from_str::<Value>(line) else {
continue;
};
if val.get("type").and_then(|v| v.as_str()) != Some("loop_check") {
continue;
}
if val.pointer("/data/session_id").and_then(|v| v.as_str()) != Some(session_id) {
continue;
}
if val
.pointer("/data/fp_read_failed")
.and_then(|v| v.as_bool())
== Some(true)
{
continue;
}
let fp = val
.pointer("/data/fingerprint")
.and_then(|v| v.as_str())
.unwrap_or("");
if last_fp.is_none() && !fp.is_empty() {
last_fp = Some(fp.to_string());
}
if fp != current_fp {
break;
}
let Some(ts) = val
.get("ts")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
else {
continue;
};
let gap = (next_ts - ts).num_seconds();
if gap < 0 || gap >= min_gap_secs {
consecutive += 1;
next_ts = ts;
oldest_counted_ts = Some(ts);
}
}
let streak_window_secs = oldest_counted_ts
.map(|t| (now - t).num_seconds().max(0))
.unwrap_or(0);
(total, consecutive, last_fp, streak_window_secs)
}
#[derive(Debug, Serialize)]
struct LoopEventEnvelope<'a> {
ts: String,
#[serde(rename = "type")]
event_type: &'a str,
source: &'static str,
data: serde_json::Value,
}
pub(crate) fn now_rfc3339_utc() -> String {
let now = chrono::Utc::now();
now.format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn append_loop_event(path: &Path, event_type: &str, data: serde_json::Value) {
let env = LoopEventEnvelope {
ts: now_rfc3339_utc(),
event_type,
source: "hook",
data,
};
let Ok(mut line) = serde_json::to_string(&env) else {
eprintln!("loop-check: failed to serialize event {event_type}");
return;
};
line.push('\n');
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
eprintln!(
"loop-check: failed to write event {event_type} to {}: {e}",
path.display()
);
}
}
Err(e) => {
eprintln!(
"loop-check: failed to open events file {}: {e}",
path.display()
);
}
}
}
pub(crate) fn emit_to_both(
project_events: &Path,
global_events: &Path,
event_type: &str,
data: serde_json::Value,
) {
append_loop_event(project_events, event_type, data.clone());
if project_events != global_events {
append_loop_event(global_events, event_type, data);
}
}
fn check_cancel_sentinel(cwd: &Path, created_at: &Option<String>) -> bool {
let sentinel = cwd.join(".fno/.target-cancelled");
let tombstone = cwd.join(".fno/.target-cancelled-final");
for path in &[&tombstone, &sentinel] {
if !path.exists() {
continue;
}
if let Some(ca) = created_at {
if let Ok(parsed_ca) = ca.parse::<DateTime<Utc>>() {
if let Ok(meta) = std::fs::metadata(path) {
if let Ok(modified) = meta.modified() {
let sentinel_time: DateTime<Utc> = modified.into();
if sentinel_time >= parsed_ca {
return true;
}
continue;
}
}
}
return true;
}
return true;
}
false
}
#[derive(Debug, PartialEq)]
enum BudgetTrip {
WallClock,
Cost,
}
enum ResolvedCap<T> {
Absent,
Valid(T),
Malformed(String),
}
fn resolve_cap<T: Copy>(cap: &Option<Result<T, String>>) -> ResolvedCap<T> {
match cap {
None => ResolvedCap::Absent,
Some(Ok(v)) => ResolvedCap::Valid(*v),
Some(Err(raw)) => ResolvedCap::Malformed(raw.clone()),
}
}
fn check_budget(
manifest: &Manifest,
settings: &Settings,
now: &DateTime<Utc>,
ledger_path: &Path,
) -> Option<BudgetTrip> {
let attended = manifest.attended;
let wall_cap = match resolve_cap(&manifest.budget_wall_clock_cap_minutes) {
ResolvedCap::Absent => {
if attended {
resolve_cap(&settings.attended_wall_cap_minutes)
} else {
resolve_cap(&settings.unattended_wall_cap_minutes)
}
}
other => other,
};
match wall_cap {
ResolvedCap::Malformed(raw) => {
eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
return Some(BudgetTrip::WallClock);
}
ResolvedCap::Valid(cap) => {
if let Some(ca_str) = &manifest.created_at {
if let Ok(created) = ca_str.parse::<DateTime<Utc>>() {
let duration = now.signed_duration_since(created);
let elapsed_min = if duration.num_minutes() < 0 {
0u64
} else {
duration.num_minutes() as u64
};
if elapsed_min >= cap {
return Some(BudgetTrip::WallClock);
}
}
}
}
ResolvedCap::Absent => {}
}
let cost_cap = match resolve_cap(&manifest.budget_cost_cap_usd) {
ResolvedCap::Absent => {
let nested = if attended {
resolve_cap(&settings.attended_cost_cap_usd)
} else {
resolve_cap(&settings.unattended_cost_cap_usd)
};
match nested {
ResolvedCap::Absent => resolve_cap(&settings.flat_budget_cap),
other => other,
}
}
other => other,
};
match cost_cap {
ResolvedCap::Malformed(raw) => {
eprintln!("loop-check: malformed budget cap '{raw}' - failing closed; fix the config");
Some(BudgetTrip::Cost)
}
ResolvedCap::Valid(cap) => {
if let Some(session_id) = &manifest.session_id {
let cost = session_cost_from_ledger(ledger_path, session_id);
if cost >= cap {
return Some(BudgetTrip::Cost);
}
}
None
}
ResolvedCap::Absent => None,
}
}
#[derive(Debug)]
struct LoopCheckArgs {
state_path: PathBuf,
transcript_path: PathBuf,
cwd: PathBuf,
global_settings_path: Option<PathBuf>,
events_path: Option<PathBuf>,
global_events_path: Option<PathBuf>,
settings_path: Option<PathBuf>,
ledger_path: Option<PathBuf>,
now_override: Option<String>,
gh_bin: String,
git_bin: String,
hook_input_stdin: bool,
}
fn parse_args(args: &[String]) -> Result<LoopCheckArgs, String> {
let mut state_path: Option<PathBuf> = None;
let mut transcript_path: Option<PathBuf> = None;
let mut cwd: Option<PathBuf> = None;
let mut global_settings_path: Option<PathBuf> = None;
let mut events_path: Option<PathBuf> = None;
let mut global_events_path: Option<PathBuf> = None;
let mut settings_path: Option<PathBuf> = None;
let mut ledger_path: Option<PathBuf> = None;
let mut now_override: Option<String> = None;
let mut gh_bin = std::env::var("FNO_LOOPCHECK_GH_BIN").unwrap_or_else(|_| "gh".to_string());
let mut git_bin = std::env::var("FNO_LOOPCHECK_GIT_BIN").unwrap_or_else(|_| "git".to_string());
let mut hook_input_stdin = false;
let args = if args.first().map(|s| s.as_str()) == Some("loop-check") {
&args[1..]
} else {
args
};
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if let Some(val) = try_flag_value(arg, "--state", args, &mut i) {
state_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--transcript", args, &mut i) {
transcript_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--cwd", args, &mut i) {
cwd = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--events", args, &mut i) {
events_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--global-events", args, &mut i) {
global_events_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--settings", args, &mut i) {
settings_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--global-settings", args, &mut i) {
global_settings_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--ledger", args, &mut i) {
ledger_path = Some(PathBuf::from(val));
} else if let Some(val) = try_flag_value(arg, "--now", args, &mut i) {
now_override = Some(val);
} else if let Some(val) = try_flag_value(arg, "--gh-bin", args, &mut i) {
gh_bin = val;
} else if let Some(val) = try_flag_value(arg, "--git-bin", args, &mut i) {
git_bin = val;
} else if arg == "--hook-input-stdin" {
hook_input_stdin = true;
}
i += 1;
}
let state_path = state_path.ok_or_else(|| "--state is required".to_string())?;
let transcript_path = transcript_path.ok_or_else(|| "--transcript is required".to_string())?;
let cwd = cwd.ok_or_else(|| "--cwd is required".to_string())?;
Ok(LoopCheckArgs {
state_path,
transcript_path,
cwd,
global_settings_path,
events_path,
global_events_path,
settings_path,
ledger_path,
now_override,
gh_bin,
git_bin,
hook_input_stdin,
})
}
fn try_flag_value(arg: &str, flag: &str, args: &[String], i: &mut usize) -> Option<String> {
if arg == flag {
*i += 1;
args.get(*i).cloned()
} else if let Some(val) = arg.strip_prefix(&format!("{flag}=")) {
Some(val.to_string())
} else {
None
}
}
pub fn decide(args: &[String]) -> (i32, String) {
let parsed = match parse_args(args) {
Ok(p) => p,
Err(e) => {
let out = serde_json::json!({ "error": e });
return (2, out.to_string());
}
};
let state_path = parsed.state_path.clone();
let transcript_path = parsed.transcript_path.clone();
let cwd = parsed.cwd.clone();
let last_assistant_message: Option<String> = if parsed.hook_input_stdin {
match std::io::read_to_string(std::io::stdin()) {
Ok(s) => extract_last_assistant_message(&s),
Err(e) => {
eprintln!(
"loop-check: failed to read hook input from stdin: {e}; falling back to transcript scan"
);
None
}
}
} else {
None
};
let manifest_content = match std::fs::read_to_string(&state_path) {
Ok(c) => c,
Err(e) => {
eprintln!(
"loop-check: cannot read state file {}: {e}",
state_path.display()
);
let out = allow_output(
"allow",
None,
"corrupt/missing manifest; allowing exit",
0,
None,
);
return (0, out);
}
};
let manifest = match parse_manifest(&manifest_content) {
Some(m) => m,
None => {
eprintln!("loop-check: corrupt manifest (no frontmatter)");
let out = allow_output(
"allow",
None,
"corrupt manifest (no frontmatter); allowing exit",
0,
None,
);
return (0, out);
}
};
if let (Some(key), Some(holder)) = (
scan_manifest_field(&manifest_content, "target_claim_key"),
scan_manifest_field(&manifest_content, "target_claim_holder"),
) {
let ttl_ms = scan_manifest_field(&manifest_content, "target_claim_ttl")
.and_then(|s| crate::claims::parse_ttl_ms(&s))
.unwrap_or(7_200_000);
match crate::claims::renew(&key, &holder, ttl_ms, None) {
Ok(_) => {}
Err(e) => eprintln!("loop-check: lease renewal for {key} failed (non-fatal): {e}"),
}
}
let project_events = parsed
.events_path
.clone()
.unwrap_or_else(|| cwd.join(".fno/events.jsonl"));
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let global_events = parsed
.global_events_path
.clone()
.unwrap_or_else(|| PathBuf::from(&home).join(".fno/events.jsonl"));
let ledger_path = parsed
.ledger_path
.clone()
.unwrap_or_else(|| cwd.join(".fno/ledger.json"));
let parse_or_emit = |content: &str, path: &Path| -> Settings {
match parse_settings_result(content) {
Ok(s) => s,
Err(e) => {
eprintln!(
"loop-check: config.toml unparseable ({}): {e} - failing the login gate closed",
path.display()
);
emit_to_both(
&project_events,
&global_events,
"loop_check_settings_unparseable",
serde_json::json!({"path": path.display().to_string(), "error": e}),
);
fail_closed_settings()
}
}
};
let settings = if let Some(ref explicit) = parsed.settings_path {
if let Ok(sc) = std::fs::read_to_string(explicit) {
parse_or_emit(&sc, explicit)
} else {
Settings::default()
}
} else {
let global_path = parsed
.global_settings_path
.clone()
.unwrap_or_else(|| PathBuf::from(&home).join(".fno/config.toml"));
let mut merged = std::fs::read_to_string(&global_path)
.map(|sc| parse_or_emit(&sc, &global_path))
.unwrap_or_default();
let local_path = cwd.join(".fno/config.toml");
if let Ok(sc) = std::fs::read_to_string(&local_path) {
let local = parse_or_emit(&sc, &local_path);
if local.attended_wall_cap_minutes.is_some() {
merged.attended_wall_cap_minutes = local.attended_wall_cap_minutes;
}
if local.attended_cost_cap_usd.is_some() {
merged.attended_cost_cap_usd = local.attended_cost_cap_usd;
}
if local.unattended_wall_cap_minutes.is_some() {
merged.unattended_wall_cap_minutes = local.unattended_wall_cap_minutes;
}
if local.unattended_cost_cap_usd.is_some() {
merged.unattended_cost_cap_usd = local.unattended_cost_cap_usd;
}
if local.flat_budget_cap.is_some() {
merged.flat_budget_cap = local.flat_budget_cap;
}
if local.ci_declared_none {
merged.ci_declared_none = true;
}
if !local.external_reviewers.is_empty() {
merged.external_reviewers = local.external_reviewers;
}
if local.required_bots.is_some() {
merged.required_bots = local.required_bots;
}
if local.github_apps.is_some() {
merged.github_apps = local.github_apps;
}
if local.optional_apps.is_some() {
merged.optional_apps = local.optional_apps;
}
if !local.reviewers.is_empty() {
merged.reviewers = local.reviewers;
}
if !local.nudge_overrides.is_empty() {
merged.nudge_overrides = local.nudge_overrides;
}
if !local.peers.is_empty() {
merged.peers = local.peers;
}
if local.peer_identity.is_some() {
merged.peer_identity = local.peer_identity;
}
if local.done_probes.is_some() {
merged.done_probes = local.done_probes;
}
}
merged
};
let author_harness = crate::claims::resolve_harness();
let required_bots = resolved_required_bots_for_author(&settings, author_harness.as_deref());
let mut required_reviewers = settings.reviewers.clone();
for reviewer in resolved_local_peer_reviewers_for_author(&settings, author_harness.as_deref()) {
if !required_reviewers.contains(&reviewer) {
required_reviewers.push(reviewer);
}
}
let optional_bots = resolved_optional_bots(&settings);
let nudge_configs = resolved_nudge_configs(&settings);
let now: DateTime<Utc> = if let Some(ref s) = parsed.now_override {
s.parse().unwrap_or_else(|_| Utc::now())
} else {
Utc::now()
};
let session_id = manifest
.session_id
.clone()
.unwrap_or_else(|| "unknown".to_string());
let emit = |event_type: &str, data: serde_json::Value| {
emit_to_both(&project_events, &global_events, event_type, data);
};
if check_cancel_sentinel(&cwd, &manifest.created_at) {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "Interrupted",
"message": "cancel sentinel present"
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::Interrupted),
"cancel sentinel present; exiting",
0,
None,
),
);
}
if let Some(ref status) = manifest.legacy_status {
emit(
"loop_check_legacy_manifest",
serde_json::json!({
"session_id": session_id,
"status": status
}),
);
return (
0,
allow_output(
"allow",
None,
&format!("legacy manifest status={status}; allowing exit"),
0,
None,
),
);
}
if let Some(trip) = check_budget(&manifest, &settings, &now, &ledger_path) {
let axis = match &trip {
BudgetTrip::WallClock => "wall_clock",
BudgetTrip::Cost => "cost",
};
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "Budget",
"axis": axis,
"message": format!("budget exceeded (axis={axis})")
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::Budget),
&format!("budget exceeded (axis={axis})"),
0,
None,
),
);
}
let generic = crate::delivery_completion::evaluate_manifest(
&cwd,
manifest.plan_path.as_deref(),
&project_events,
);
let gh_bin = &parsed.gh_bin;
let gh_available = {
match Command::new(gh_bin).arg("--version").output() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => false,
Ok(_) => true, }
};
if !gh_available
&& matches!(
generic,
crate::delivery_completion::DeliveryCompletion::Inactive
)
{
if !manifest.attended && !manifest.advisory {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "Interrupted",
"message": "gh binary not found; unattended sessions require gh"
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::Interrupted),
"gh binary not found; unattended sessions require gh",
0,
None,
),
);
}
emit(
"loop_advisory_mode",
serde_json::json!({
"session_id": session_id,
"attended": manifest.attended
}),
);
let (advisory_intent, _advisory_intent_source) =
detect_intent(last_assistant_message.as_deref(), &transcript_path);
if let Intent::Aborted { ref reason } = advisory_intent {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "Aborted",
"message": reason
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::Aborted),
"aborted tag detected (advisory mode)",
0,
None,
),
);
}
if advisory_intent == Intent::Promise {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "DoneAdvisory",
"message": "promise accepted in advisory mode (gh unavailable)"
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::DoneAdvisory),
"promise accepted in advisory mode (gh unavailable)",
0,
None,
),
);
}
return (
0,
allow_output(
"block",
None,
"gh binary not found; running in advisory mode (promise + budget only)",
0,
None,
),
);
}
let (intent, intent_source) =
detect_intent(last_assistant_message.as_deref(), &transcript_path);
let git_bin = &parsed.git_bin;
let head_sha = git_head_sha(git_bin, &cwd);
let backstop_n: u64 = if manifest.attended { 5 } else { 3 };
let fp_read_result = Command::new(gh_bin)
.args(["pr", "view", "--json", "state,number,headRefName"])
.current_dir(&cwd)
.output();
let (fp_pr_state, fp_ci, fp_review_ts, fp_read_failed) = match fp_read_result {
Ok(o) if o.status.success() => {
let pv: Value = serde_json::from_slice(&o.stdout).unwrap_or(Value::Null);
let state =
PrState::from_gh_str(pv.get("state").and_then(|v| v.as_str()).unwrap_or("none"));
let ci = match Command::new(gh_bin)
.args(["pr", "checks", "--json", "name,state,bucket"])
.current_dir(&cwd)
.output()
{
Ok(co) if co.status.success() => {
let cv: Value = serde_json::from_slice(&co.stdout).unwrap_or(Value::Null);
compute_ci_conclusion(&cv).unwrap_or(CiConclusion::None)
}
_ => CiConclusion::None,
};
let rv_ts = if !manifest.no_external && !required_bots.is_empty() {
match Command::new(gh_bin)
.args(["pr", "view", "--json", "reviews,comments"])
.current_dir(&cwd)
.output()
{
Ok(ro) if ro.status.success() => {
let rv: Value = serde_json::from_slice(&ro.stdout).unwrap_or(Value::Null);
compute_review_info(&rv, &required_bots).latest_ts
}
_ => "none".to_string(),
}
} else {
"none".to_string()
};
(state, ci, rv_ts, false)
}
Ok(o) if is_no_pr_stderr(&o.stderr) => {
(PrState::None, CiConclusion::None, "none".to_string(), false)
}
_ => (PrState::None, CiConclusion::None, "none".to_string(), true),
};
let tentative_fp = generic.delivery_fingerprint(make_fingerprint(
&head_sha,
fp_pr_state.as_str(),
&fp_ci.render(),
&fp_review_ts,
));
let min_fire_gap = min_fire_gap_secs();
let (prior_fires, consecutive_unchanged, last_recorded_fp, streak_window) = read_prior_fires(
&project_events,
&session_id,
&tentative_fp,
now,
min_fire_gap,
);
let fingerprint = if fp_read_failed && !generic.is_active() {
last_recorded_fp.unwrap_or(tentative_fp)
} else {
tentative_fp
};
let (consecutive_unchanged, streak_window) = if fp_read_failed && !generic.is_active() {
let (_, streak, _, window) = read_prior_fires(
&project_events,
&session_id,
&fingerprint,
now,
min_fire_gap,
);
(streak, window)
} else {
(consecutive_unchanged, streak_window)
};
let this_fire = prior_fires + 1;
let consecutive_after = if fp_read_failed {
consecutive_unchanged
} else {
consecutive_unchanged + 1
};
let backstop_tripped = consecutive_after >= backstop_n;
const MUTE_PROBE_N: u64 = 2;
let node_id = scan_manifest_field(&manifest_content, "graph_node_id").or_else(|| {
scan_manifest_field(&manifest_content, "target_claim_key")
.and_then(|k| k.strip_prefix("node:").map(|s| s.to_string()))
});
let (open_findings, malformed_findings) = match node_id.as_deref() {
Some(n) => open_review_findings(&project_events, n),
None => (Vec::new(), 0),
};
if malformed_findings > 0 {
emit(
"loop_check_malformed_finding",
serde_json::json!({
"session_id": session_id,
"node": node_id,
"malformed_lines": malformed_findings
}),
);
}
if generic.is_active()
|| intent != Intent::None
|| backstop_tripped
|| consecutive_after >= MUTE_PROBE_N
{
if let Intent::Aborted { ref reason } = intent {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "Aborted",
"message": reason
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": "aborted",
"intent_source": intent_source,
"pr_state": fp_pr_state.as_str(),
"ci": fp_ci.render(),
"reviewed": false,
"fp_read_failed": fp_read_failed
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::Aborted),
"aborted tag detected",
this_fire,
Some(fingerprint),
),
);
}
if !open_findings.is_empty()
&& !backstop_tripped
&& (intent == Intent::Promise || consecutive_after >= MUTE_PROBE_N)
{
let reason = build_findings_block_reason(&open_findings, malformed_findings);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "block",
"intent": if intent == Intent::Promise { "promise" } else { "backstop" },
"intent_source": intent_source,
"pr_state": fp_pr_state.as_str(),
"ci": fp_ci.render(),
"reviewed": false,
"open_findings": open_findings.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(),
"malformed_findings": malformed_findings,
"fp_read_failed": fp_read_failed
}),
);
return (
0,
allow_output("block", None, &reason, this_fire, Some(fingerprint)),
);
}
if let Some(output) = crate::delivery_completion::gate_output(
&generic,
intent == Intent::Promise,
&project_events,
&global_events,
&session_id,
manifest.session_id.as_deref(),
node_id.as_deref(),
intent_source,
&fingerprint,
this_fire,
backstop_tripped,
consecutive_after,
streak_window,
fp_pr_state.as_str(),
&fp_ci.render(),
) {
return (0, output);
}
if manifest.planned && intent == Intent::Promise {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "DonePlanned",
"message": "promise in plan-only unit"
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": "promise",
"intent_source": intent_source,
"pr_state": fp_pr_state.as_str(),
"ci": fp_ci.render(),
"reviewed": true,
"fp_read_failed": fp_read_failed
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::DonePlanned),
"promise + plan-only unit; done",
this_fire,
Some(fingerprint),
),
);
}
if (manifest.no_ship || manifest.advisory) && intent == Intent::Promise {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "DoneAdvisory",
"message": "promise in advisory/no_ship unit"
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": "promise",
"intent_source": intent_source,
"pr_state": fp_pr_state.as_str(),
"ci": fp_ci.render(),
"reviewed": true,
"fp_read_failed": fp_read_failed
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::DoneAdvisory),
"promise + advisory unit; done",
this_fire,
Some(fingerprint),
),
);
}
if manifest.batched && intent == Intent::Promise {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "DoneBatched",
"message": "promise in batched unit; commit landed on shared branch"
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": "promise",
"intent_source": intent_source,
"pr_state": fp_pr_state.as_str(),
"ci": fp_ci.render(),
"reviewed": true,
"fp_read_failed": fp_read_failed
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::DoneBatched),
"promise + batched unit; commit on shared branch, batch PR ships it",
this_fire,
Some(fingerprint),
),
);
}
let done_result = run_done(
gh_bin,
&cwd,
settings.ci_declared_none,
manifest.no_external,
&required_bots,
&optional_bots,
&settings.external_reviewers,
&required_reviewers,
&nudge_configs,
&head_sha,
&project_events,
);
match done_result {
Ok(mut pr_info) => {
let (fingerprint, consecutive_after, streak_window) = if !fp_read_failed {
let done_fp = make_fingerprint(
&head_sha,
fp_pr_state.as_str(),
&fp_ci.render(),
&max_ts(&fp_review_ts, &pr_info.latest_review_ts),
);
if done_fp != fingerprint {
let (_, streak, _, window) = read_prior_fires(
&project_events,
&session_id,
&done_fp,
now,
min_fire_gap,
);
(done_fp, streak + 1, window)
} else {
(fingerprint, consecutive_after, streak_window)
}
} else {
(fingerprint, consecutive_after, streak_window)
};
let backstop_tripped = consecutive_after >= backstop_n;
let nudge_pr_number = pr_info.number;
for n in pr_info.bot_nudges.iter_mut() {
if n.class != NudgeClass::NeedsNudge {
continue;
}
if post_nudge_comment(gh_bin, &cwd, nudge_pr_number, &n.review_handle) {
emit(
"loop_check_nudge_posted",
serde_json::json!({
"session_id": session_id,
"pr": nudge_pr_number,
"bot": n.login,
"handle": n.review_handle,
"nudge": n.nudges + 1,
"ceiling": n.ceiling
}),
);
n.nudges += 1;
n.newest_age_min = 0;
n.class = NudgeClass::Awaiting;
} else {
emit(
"loop_check_nudge_post_failed",
serde_json::json!({
"session_id": session_id,
"pr": nudge_pr_number,
"bot": n.login,
"handle": n.review_handle
}),
);
}
}
let ci_ok = pr_info.ci_conclusion.is_ok();
let pr_open = pr_info.state.is_open_or_merged();
let head_shipped = !pr_info.head_oid.is_empty() && pr_info.head_oid == head_sha;
let (mut probe_block, mut probe_results) = (None, Value::Null);
if pr_open && ci_ok && pr_info.reviewed && head_shipped {
match evaluate_done_probes(
manifest.plan_path.as_deref(),
settings.done_probes.as_ref(),
&cwd,
&project_events,
&session_id,
PROBE_TIMEOUT,
) {
ProbeGate::Absent => {}
ProbeGate::Pass(results) => probe_results = results,
ProbeGate::Fail { reason, results } => {
probe_block = Some(reason);
probe_results = results;
}
}
}
let (reviewed, probes_passed) = (pr_info.reviewed, probe_block.is_none());
if pr_passes(pr_open, ci_ok, reviewed, head_shipped, probes_passed) {
let done_msg = if pr_info.usage_limited.is_empty() {
format!("PR #{} is green and reviewed", pr_info.number)
} else {
format!(
"PR #{} is green and reviewed (rate-limited, dropped from gate: {})",
pr_info.number,
pr_info.usage_limited.join(", ")
)
};
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "DonePRGreen",
"message": done_msg.clone()
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": if intent == Intent::Promise { "promise" } else { "backstop" },
"intent_source": intent_source,
"pr_state": pr_info.state.as_str(),
"ci": pr_info.ci_conclusion.render(),
"reviewed": pr_info.reviewed,
"review_skipped": pr_info.review_skipped,
"unaddressed_blocking": pr_info.unaddressed_findings.len(),
"fp_read_failed": fp_read_failed,
"done_probes": probe_results
}),
);
return (
0,
allow_output(
"allow",
Some(TerminationReason::DonePRGreen),
&done_msg,
this_fire,
Some(fingerprint),
),
);
}
if pr_open
&& pr_info.reviewed
&& head_shipped
&& !ci_ok
&& !pr_info.ci_has_pending
&& pr_info.mergeable != "CONFLICTING"
{
if let Some(main_failing) =
main_head_failing_checks(gh_bin, &cwd, MAIN_RUN_LOOKBACK)
{
if is_pre_existing_main_red(&pr_info.failing_checks, &main_failing) {
let proof = format!(
"same checks red on main (last {} completed runs): {}",
MAIN_RUN_LOOKBACK,
pr_info.failing_checks.join(", ")
);
let msg = format!(
"PR #{} complete and reviewed; awaiting merge past pre-existing main-red ({proof})",
pr_info.number
);
if !already_emitted_awaiting_merge(&project_events, &session_id) {
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "DoneAwaitingMerge",
"message": msg.clone()
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": if intent == Intent::Promise { "promise" } else { "backstop" },
"intent_source": intent_source,
"pr_state": pr_info.state.as_str(),
"ci": pr_info.ci_conclusion.render(),
"reviewed": pr_info.reviewed,
"review_skipped": pr_info.review_skipped,
"unaddressed_blocking": pr_info.unaddressed_findings.len(),
"fp_read_failed": fp_read_failed
}),
);
best_effort_notify(
&format!(
"PR #{} ready - merge past pre-existing main-red",
pr_info.number
),
&msg,
);
}
return (
0,
allow_output(
"allow",
Some(TerminationReason::DoneAwaitingMerge),
&msg,
this_fire,
Some(fingerprint),
),
);
}
}
}
if let Intent::Watching {
ref reason,
ref timeout,
..
} = intent
{
let blocker = if harness_can_idle(
author_harness.as_deref(),
std::env::var("FNO_DRIVER_LIB").is_ok(),
) {
async_wait_class(&pr_info, &head_sha, open_findings.is_empty())
} else {
None
};
if let Some(blocker) = blocker {
let window_ms = watch_window_ms(timeout.as_deref());
let renewed = match (
scan_manifest_field(&manifest_content, "target_claim_key"),
scan_manifest_field(&manifest_content, "target_claim_holder"),
) {
(Some(key), Some(holder)) => matches!(
crate::claims::renew(&key, &holder, window_ms, None),
Ok(true)
),
_ => false,
};
if renewed {
emit(
"loop_check_watch_idle",
serde_json::json!({
"session_id": session_id,
"pr": pr_info.number,
"blocker": blocker,
"declared_timeout": timeout.clone().unwrap_or_default(),
"reason": reason,
"lease_ms": window_ms
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": "watching",
"intent_source": intent_source,
"pr_state": pr_info.state.as_str(),
"ci": pr_info.ci_conclusion.render(),
"reviewed": pr_info.reviewed,
"review_skipped": pr_info.review_skipped,
"fp_read_failed": fp_read_failed
}),
);
let msg = format!(
"watching: idling until watcher fires (PR #{}, {blocker} pending)",
pr_info.number
);
return (
0,
allow_output("allow", None, &msg, this_fire, Some(fingerprint)),
);
}
}
}
let sole_blocker_is_awaiting = pr_open
&& ci_ok
&& probe_block.is_none()
&& !pr_info.reviewed
&& pr_info.unattested_reviewers.is_empty()
&& pr_info.unaddressed_findings.is_empty()
&& pr_info
.bot_nudges
.iter()
.any(|n| n.class == NudgeClass::Awaiting);
if backstop_tripped
&& (!pr_open || !ci_ok || !pr_info.reviewed || probe_block.is_some())
&& !sole_blocker_is_awaiting
{
let nudge_giveup = unresponsive_bot(&pr_info);
let noprogress_msg = match nudge_giveup {
Some(n) => nudge_giveup_message(n),
None => format!(
"fingerprint unchanged for {} consecutive fires over {}m; PR not done",
consecutive_after,
streak_window / 60
),
};
if let Some(n) = nudge_giveup {
best_effort_notify(
"target: bot review gave up",
&format!(
"PR #{}: {} did not review after {} nudges over {}m",
pr_info.number, n.login, n.nudges, n.span_min
),
);
}
emit(
"termination",
serde_json::json!({
"session_id": session_id,
"reason": "NoProgress",
"message": noprogress_msg
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "allow",
"intent": "backstop",
"intent_source": intent_source,
"pr_state": pr_info.state.as_str(),
"ci": pr_info.ci_conclusion.render(),
"reviewed": pr_info.reviewed,
"review_skipped": pr_info.review_skipped,
"unaddressed_blocking": pr_info.unaddressed_findings.len(),
"fp_read_failed": fp_read_failed,
"done_probes": probe_results
}),
);
let return_msg = match nudge_giveup {
Some(_) => noprogress_msg.clone(),
None => format!(
"fingerprint unchanged for {} fires over {}m; HEAD={}, PR={}, CI={}, reviewed={}",
consecutive_after,
streak_window / 60,
short_sha(&head_sha),
pr_info.state.as_str(),
pr_info.ci_conclusion.render(),
pr_info.reviewed
),
};
return (
0,
allow_output(
"allow",
Some(TerminationReason::NoProgress),
&return_msg,
this_fire,
Some(fingerprint),
),
);
}
let reason = crate::nudge::append_inbox_nudge(
&probe_block.clone().unwrap_or_else(|| {
build_block_reason(&pr_info, &head_sha, open_findings.is_empty())
}),
&cwd,
&session_id,
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "block",
"intent": if intent == Intent::Promise { "promise" } else { "none" },
"intent_source": intent_source,
"pr_state": pr_info.state.as_str(),
"ci": pr_info.ci_conclusion.render(),
"reviewed": pr_info.reviewed,
"review_skipped": pr_info.review_skipped,
"unaddressed_blocking": pr_info.unaddressed_findings.len(),
"fp_read_failed": fp_read_failed,
"done_probes": probe_results
}),
);
return (
0,
allow_output("block", None, &reason, this_fire, Some(fingerprint)),
);
}
Err((failed_read, failed_stderr)) => {
emit(
"loop_check_gh_error",
serde_json::json!({
"session_id": session_id,
"read": failed_read,
"stderr_tail": failed_stderr
}),
);
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "block",
"intent": if intent == Intent::Promise { "promise" } else { "none" },
"intent_source": intent_source,
"pr_state": "unknown",
"ci": "unknown",
"reviewed": false,
"fp_read_failed": true
}),
);
return (
0,
allow_output(
"block",
None,
&format!("gh read '{failed_read}' failed; retrying next fire"),
this_fire,
Some(fingerprint),
),
);
}
}
}
emit(
"loop_check",
serde_json::json!({
"session_id": session_id,
"fingerprint": fingerprint,
"fires": this_fire,
"consecutive_unchanged": consecutive_after,
"streak_window_secs": streak_window,
"decision": "block",
"intent": "none",
"intent_source": intent_source,
"pr_state": fp_pr_state.as_str(),
"ci": fp_ci.render(),
"reviewed": false,
"fp_read_failed": fp_read_failed
}),
);
let continue_msg = crate::nudge::append_inbox_nudge(
"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.",
&cwd,
&session_id,
);
(
0,
allow_output("block", None, &continue_msg, this_fire, Some(fingerprint)),
)
}
#[allow(clippy::too_many_arguments)]
fn run_done(
gh_bin: &str,
cwd: &Path,
ci_declared_none: bool,
no_external: bool,
required_bots: &[String],
optional_bots: &[String],
external_reviewers: &[String],
reviewers: &[String],
nudge_configs: &[NudgeConfig],
head_sha: &str,
events_path: &Path,
) -> Result<PrInfo, (String, String)> {
read_pr_info(
gh_bin,
cwd,
ci_declared_none,
no_external,
required_bots,
optional_bots,
external_reviewers,
reviewers,
nudge_configs,
head_sha,
events_path,
)
}
const WATCH_SLACK_MS: i64 = 12 * 60_000;
fn watch_window_ms(timeout: Option<&str>) -> i64 {
let declared = timeout
.and_then(crate::claims::parse_ttl_ms)
.unwrap_or(30 * 60_000);
declared.clamp(5 * 60_000, 2 * 3_600_000) + WATCH_SLACK_MS
}
fn harness_can_idle(author_harness: Option<&str>, is_loop_run_child: bool) -> bool {
author_harness == Some("claude") && !is_loop_run_child
}
fn async_wait_class(
pr: &PrInfo,
local_head: &str,
open_findings_empty: bool,
) -> Option<&'static str> {
let head_shipped = !pr.head_oid.is_empty() && pr.head_oid == local_head;
if pr.state != PrState::Open
|| !head_shipped
|| !pr.unaddressed_findings.is_empty()
|| !open_findings_empty
{
return None;
}
if pr.ci_has_pending && !matches!(pr.ci_conclusion, CiConclusion::Failure(_)) {
return Some("ci");
}
if pr.ci_conclusion.is_ok()
&& !pr.reviewed
&& !pr.review_skipped
&& !pr.missing_bots.is_empty()
&& pr.unattested_reviewers.is_empty()
&& pr.bot_nudges.iter().all(|n| nudge_class_idlable(&n.class))
{
return Some("review");
}
None
}
fn short_sha(s: &str) -> String {
s.chars().take(8).collect()
}
fn arm_watch_hint(pr_number: i64, blocker: &str) -> String {
let watcher = if blocker == "review" {
format!(
"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)"
)
} else {
format!(
"background Bash `gh pr checks {pr_number} --watch & w=$!; (sleep 1800; kill $w 2>/dev/null) & k=$!; wait $w; kill $k 2>/dev/null`"
)
};
format!(
" 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."
)
}
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const PROBE_CAP: usize = 3;
const PROBE_STDERR_CAP: usize = 500;
enum ProbeOutcome {
Pass,
Fail { code: Option<i32>, stderr: String },
Timeout,
}
impl ProbeOutcome {
fn render(&self) -> String {
match self {
ProbeOutcome::Pass => "pass".to_string(),
ProbeOutcome::Fail { code: Some(c), .. } => format!("fail:{c}"),
ProbeOutcome::Fail { code: None, .. } => "fail:signal".to_string(),
ProbeOutcome::Timeout => "timeout".to_string(),
}
}
}
enum ProbeGate {
Absent,
Pass(Value),
Fail {
reason: String,
results: Value,
},
}
fn unquote_scalar(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
let inner = &s[1..s.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('0') => out.push('\0'),
Some(other) => out.push(other),
None => out.push('\\'),
}
}
return out;
}
if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
return s[1..s.len() - 1].replace("''", "'");
}
s.to_string()
}
fn split_inline_list(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = body.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' || c == '\'' {
let mut item = String::new();
let mut escaped = false;
for c2 in chars.by_ref() {
if escaped {
item.push(c2);
escaped = false;
} else if c2 == '\\' {
escaped = true;
} else if c2 == c {
break;
} else {
item.push(c2);
}
}
out.push(item);
}
}
if out.is_empty() {
out = body
.split(',')
.map(unquote_scalar)
.filter(|s| !s.is_empty())
.collect();
}
out
}
#[derive(Debug, PartialEq)]
enum ProbeDecl {
None,
Probes(Vec<String>),
Unparseable,
}
fn parse_done_probes(content: &str) -> ProbeDecl {
let content = content.trim_start();
if !content.starts_with("---") {
return ProbeDecl::None;
}
let after_first = &content[3..];
let Some(end) = after_first.find("\n---") else {
return ProbeDecl::None;
};
let mut out = Vec::new();
let mut declared = false;
let mut in_block = false;
for line in after_first[..end].lines() {
let trimmed = line.trim();
if !in_block {
let Some(rest) = trimmed.strip_prefix("done_probes:") else {
continue;
};
declared = true;
let rest = rest.trim();
if rest == "[]" {
return ProbeDecl::None;
}
if let Some(inner) = rest.strip_prefix('[') {
let Some(inner) = inner.strip_suffix(']') else {
return ProbeDecl::Unparseable;
};
let items = split_inline_list(inner);
return if items.is_empty() {
ProbeDecl::Unparseable
} else {
ProbeDecl::Probes(items)
};
}
in_block = true;
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some(item) = trimmed.strip_prefix("- ") else {
break; };
let item = unquote_scalar(item);
if !item.is_empty() {
out.push(item);
}
}
match (declared, out.is_empty()) {
(false, _) => ProbeDecl::None,
(true, true) => ProbeDecl::Unparseable,
(true, false) => ProbeDecl::Probes(out),
}
}
fn keep_last_on_char_boundary(s: &mut String, cap: usize) {
if s.len() <= cap {
return;
}
let start = s.len() - cap;
let cut = (start..=s.len())
.find(|i| s.is_char_boundary(*i))
.unwrap_or(s.len());
s.drain(..cut);
}
fn killpg(pgid: i32) {
if pgid <= 0 {
return;
}
unsafe {
libc::killpg(pgid, libc::SIGKILL);
}
}
fn run_probe(cmd: &str, cwd: &Path, timeout: std::time::Duration) -> ProbeOutcome {
use std::os::unix::process::CommandExt;
let spawned = Command::new("sh")
.arg("-c")
.arg(cmd)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.process_group(0)
.spawn();
let mut child = match spawned {
Ok(c) => c,
Err(e) => {
return ProbeOutcome::Fail {
code: Some(127),
stderr: format!("probe spawn failed: {e}"),
}
}
};
let pgid = child.id() as i32;
let mut pipe = child.stderr.take();
let drain = std::thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
let start = std::time::Instant::now();
let outcome = loop {
match child.try_wait() {
Ok(Some(status)) => {
break if status.success() {
ProbeOutcome::Pass
} else {
ProbeOutcome::Fail {
code: status.code(),
stderr: String::new(),
}
};
}
Ok(None) => {
if start.elapsed() >= timeout {
kill_process_group(&mut child);
break ProbeOutcome::Timeout;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => {
kill_process_group(&mut child);
break ProbeOutcome::Fail {
code: None,
stderr: format!("probe wait failed: {e}"),
};
}
}
};
killpg(pgid);
if matches!(outcome, ProbeOutcome::Timeout) {
return outcome;
}
let mut stderr = drain.join().unwrap_or_default();
keep_last_on_char_boundary(&mut stderr, PROBE_STDERR_CAP);
match outcome {
ProbeOutcome::Fail { code, stderr: s } if s.is_empty() => {
ProbeOutcome::Fail { code, stderr }
}
other => other,
}
}
fn kill_process_group(child: &mut std::process::Child) {
killpg(child.id() as i32);
let _ = child.kill();
let _ = child.wait();
}
fn undeterminable_marker(cause: &str) -> Value {
serde_json::json!({ "_undeterminable": cause })
}
fn prior_fires_declared_probes(events_path: &Path, session_id: &str) -> bool {
let Ok(content) = std::fs::read_to_string(events_path) else {
return false;
};
content.lines().any(|line| {
let Ok(val) = serde_json::from_str::<Value>(line) else {
return false;
};
val.get("type").and_then(|v| v.as_str()) == Some("loop_check")
&& val.pointer("/data/session_id").and_then(|v| v.as_str()) == Some(session_id)
&& val
.pointer("/data/done_probes")
.and_then(|v| v.as_object())
.is_some_and(|m| !m.is_empty())
})
}
fn plan_declared_probes(
plan_path: Option<&str>,
cwd: &Path,
events_path: &Path,
session_id: &str,
) -> Result<Vec<String>, ProbeGate> {
let plan = plan_path.and_then(|p| {
let p = Path::new(p.split('#').next().unwrap_or(p));
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
};
std::fs::read_to_string(abs).ok()
});
let Some(plan) = plan else {
if prior_fires_declared_probes(events_path, session_id) {
return Err(ProbeGate::Fail {
reason: format!(
"done_probes undeterminable: plan {} is unreadable but a prior fire declared probes; restore the plan doc",
plan_path.unwrap_or("(unset)")
),
results: undeterminable_marker("plan-unreadable"),
});
}
return Ok(Vec::new());
};
let probes = match parse_done_probes(&plan) {
ProbeDecl::None => return Ok(Vec::new()),
ProbeDecl::Unparseable => {
return Err(ProbeGate::Fail {
reason: format!(
"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)",
plan_path.unwrap_or("(unset)")
),
results: undeterminable_marker("unparseable-declaration"),
})
}
ProbeDecl::Probes(p) => p,
};
if probes.len() > PROBE_CAP {
return Err(ProbeGate::Fail {
reason: format!(
"plan declares {} done_probes; the cap is {PROBE_CAP} per source (a probe list is a gate, not a test suite)",
probes.len()
),
results: undeterminable_marker("over-cap"),
});
}
Ok(probes)
}
fn evaluate_done_probes(
plan_path: Option<&str>,
config_probes: Option<&Result<Vec<String>, String>>,
cwd: &Path,
events_path: &Path,
session_id: &str,
timeout: std::time::Duration,
) -> ProbeGate {
let project = match config_probes {
None => Vec::new(),
Some(Err(why)) => {
return ProbeGate::Fail {
reason: format!(
"done_probes undeterminable: config.toml declares `done_probes` but {why}"
),
results: undeterminable_marker("unparseable-config-declaration"),
}
}
Some(Ok(p)) => p.clone(),
};
if project.len() > PROBE_CAP {
return ProbeGate::Fail {
reason: format!(
"config.toml declares {} done_probes; the cap is {PROBE_CAP} per source (a probe list is a gate, not a test suite)",
project.len()
),
results: undeterminable_marker("over-cap"),
};
}
let plan_probes = match plan_declared_probes(plan_path, cwd, events_path, session_id) {
Ok(p) => p,
Err(gate) => return gate,
};
if project.is_empty() && plan_probes.is_empty() {
return ProbeGate::Absent;
}
let mut results = serde_json::Map::new();
let mut failures = Vec::new();
for (source, cmd) in project
.iter()
.map(|c| ("project", c))
.chain(plan_probes.iter().map(|c| ("plan", c)))
{
let outcome = run_probe(cmd, cwd, timeout);
results.insert(cmd.clone(), Value::String(outcome.render()));
match &outcome {
ProbeOutcome::Pass => {}
ProbeOutcome::Timeout => failures.push(format!(
"{source} probe `{cmd}` timed out after {}s (killed)",
timeout.as_secs()
)),
ProbeOutcome::Fail { code, stderr } => {
let code = code.map(|c| c.to_string()).unwrap_or("signal".to_string());
let tail = if stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr.trim())
};
failures.push(format!("{source} probe `{cmd}` exited {code}{tail}"));
}
}
}
let results = Value::Object(results);
if failures.is_empty() {
ProbeGate::Pass(results)
} else {
ProbeGate::Fail {
reason: format!(
"done_probes failed - the shipped thing has no evidence of running: {}",
failures.join("; ")
),
results,
}
}
}
fn build_block_reason(pr: &PrInfo, local_head: &str, open_findings_empty: bool) -> String {
let idlable = async_wait_class(pr, local_head, open_findings_empty);
let hint = |blocker: &str| -> String {
if idlable == Some(blocker) {
arm_watch_hint(pr.number, blocker)
} else {
String::new()
}
};
if !pr.state.is_open_or_merged() {
return format!(
"no PR for HEAD (pr_state={}); keep working",
pr.state.as_str()
);
}
if !pr.head_oid.is_empty() && pr.head_oid != local_head {
return format!(
"PR #{} head {} != local HEAD {}: push the latest commits before completing",
pr.number,
short_sha(&pr.head_oid),
short_sha(local_head)
);
}
if !pr.ci_conclusion.is_ok() {
if pr.ci_conclusion == CiConclusion::None {
return format!(
"no CI checks found on PR #{}; declare ci.declared_none: true in settings if intentional",
pr.number
);
}
if pr.ci_conclusion == CiConclusion::Pending {
return format!("CI still running on PR #{}.{}", pr.number, hint("ci"));
}
let check_name = match &pr.ci_conclusion {
CiConclusion::Failure(Some(name)) => name.as_str(),
_ => "CI",
};
return format!("CI red on PR #{}: {} failed", pr.number, check_name);
}
if !pr.reviewed {
if !pr.unaddressed_findings.is_empty() {
let f = &pr.unaddressed_findings[0];
let more = if pr.unaddressed_findings.len() > 1 {
format!(" [+{} more]", pr.unaddressed_findings.len() - 1)
} else {
String::new()
};
let reply_to = profile_by_author(&f.author)
.map(|p| format!(" addressed to {}", p.reply_handle))
.unwrap_or_default();
return format!(
"PR #{}: {} {} at {}:{} unaddressed (reply in-thread{} or wontfix:){}",
pr.number, f.author, f.severity, f.path, f.line, reply_to, more
);
}
if !pr.unattested_reviewers.is_empty() {
let head = short_sha(local_head);
let items: Vec<String> = pr
.unattested_reviewers
.iter()
.map(|r| {
let state = if r.failed_at_head {
" (attested at this head, verdict NOT pass)".to_string()
} else {
match &r.superseded_head {
Some(h) => {
format!(" (passed at {}, superseded by this head)", short_sha(h))
}
None => String::new(),
}
};
if r.name == SAME_MODEL_LOCAL_PEER_SENTINEL {
return format!(
"peer{} -> configure a cross-model peer or routed model",
state
);
}
if r.name == LOCAL_PEER_REVIEWER {
return format!("peer{} -> run `/fno:review peer --attest`", state);
}
match reviewer_invocation(&r.name) {
Some((inv, self_cert)) => {
let mark = if self_cert {
" [self-cert: asserts no review evidence]"
} else {
""
};
format!("{}{} -> run `{}`{}", r.name, state, inv, mark)
}
None => format!("{}{}", r.name, state),
}
})
.collect();
let corrupt = match pr.malformed_attestations {
0 => String::new(),
n => format!(" ({n} unparseable attestation line(s) ignored)"),
};
return format!(
"PR #{}: reviewers gate unmet - no head-pinned review_attestation at {} for {}{}. \
This is local work to DO, not a wait: no GitHub reviewer posts these, \
so do not arm a watcher.",
pr.number,
head,
items.join("; "),
corrupt
);
}
if !pr.missing_bots.is_empty() {
if let Some(n) = pr
.bot_nudges
.iter()
.find(|n| n.class == NudgeClass::NeedsNudge)
{
return format!(
"PR #{}: {} reviews on mention, not on push, and has not been asked. Run:\n \
gh pr comment {} --body \"{}\"\nthen arm a watcher (nudge {} of {}).{}",
pr.number,
n.login,
pr.number,
n.review_handle,
n.nudges + 1,
n.ceiling,
hint("review")
);
}
if let Some(n) = pr
.bot_nudges
.iter()
.find(|n| n.class == NudgeClass::Unresponsive)
{
return format!(
"PR #{}: {} did not review after {} nudges over {}m. Nothing further \
will arrive on its own. Either post the review by hand, or move this \
login to config.review.optional_apps (honored-if-present, never waited \
on). Not a wait: do not arm a watcher.{}",
pr.number,
n.login,
n.nudges,
n.span_min,
hint("review")
);
}
if let Some(n) = pr
.bot_nudges
.iter()
.find(|n| n.class == NudgeClass::Awaiting)
{
return format!(
"PR #{}: {} nudged {}m ago ({} of {}), awaiting review.{}",
pr.number,
n.login,
n.newest_age_min,
n.nudges,
n.ceiling,
hint("review")
);
}
return format!(
"PR #{}: {} has not reviewed.{}",
pr.number,
pr.missing_bots.join(", "),
hint("review")
);
}
return format!(
"PR #{} not yet reviewed and no reviewer is outstanding; \
re-check config.review (required_bots / reviewers) - nothing here will \
arrive on its own.",
pr.number
);
}
format!("PR #{} done() returned false (unknown reason)", pr.number)
}
pub fn run_loop_check(args: &[String]) -> i32 {
let (code, json) = decide(args);
println!("{json}");
code
}
pub fn run_loop_check_capture(args: &[String]) -> (i32, String) {
decide(args)
}
#[cfg(test)]
mod tests {
use super::*;
fn unattested_reviewers(
events_path: &Path,
reviewers: &[String],
head_sha: &str,
) -> Vec<UnattestedReviewer> {
unattested_reviewers_scan(events_path, reviewers, head_sha).0
}
fn reviewers_all_attested(events_path: &Path, reviewers: &[String], head_sha: &str) -> bool {
unattested_reviewers(events_path, reviewers, head_sha).is_empty()
}
const FP: &str = "FP";
const NOW: &str = "2026-06-05T12:00:00Z";
fn at(ts: &str) -> DateTime<Utc> {
ts.parse().unwrap()
}
fn write_fire_log(path: &Path, fires: &[(String, &str)]) {
let mut out = String::new();
for (ts, fp) in fires {
out.push_str(
&serde_json::json!({
"ts": ts, "type": "loop_check", "source": "hook",
"data": { "session_id": "sess", "fingerprint": fp },
})
.to_string(),
);
out.push('\n');
}
std::fs::write(path, out).unwrap();
}
fn streak_ago(secs_before_now: &[i64], gap: i64) -> (u64, i64) {
let now = at(NOW);
let fires: Vec<(String, &str)> = secs_before_now
.iter()
.map(|s| {
(
(now - chrono::Duration::seconds(*s))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string(),
FP,
)
})
.collect();
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("events.jsonl");
write_fire_log(&p, &fires);
let (_, streak, _, window) = read_prior_fires(&p, "sess", FP, now, gap);
(streak, window)
}
#[test]
fn debounce_streak_counting_rules() {
#[rustfmt::skip]
let cases: &[(&str, &[i64], i64, u64, i64)] = &[
("rapid burst collapses to one observation", &[49, 33, 16, 0], 300, 0, 0),
("fires 6 minutes apart still trip the backstop", &[1440, 1080, 720, 360], 300, 4, 1440),
("a skip does not advance the cursor", &[330, 60, 30, 10], 300, 1, 330),
("gap 0 restores fire counting exactly", &[49, 33, 16], 0, 3, 49),
("a fire stamped after `now` counts, not crashes", &[1200, -600], 300, 2, 1200),
("the false-NoProgress incident now blocks", &[109, 93, 76, 17], 300, 0, 0),
];
for (case, fires, gap, want_streak, want_window) in cases {
let (streak, window) = streak_ago(fires, *gap);
assert_eq!(streak, *want_streak, "streak: {case}");
assert_eq!(window, *want_window, "window: {case}");
}
}
#[test]
fn debounce_changed_fingerprint_breaks_streak_at_any_speed() {
let now = at(NOW);
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("events.jsonl");
write_fire_log(
&p,
&[
("2026-06-05T11:40:00Z".to_string(), FP),
("2026-06-05T11:50:00Z".to_string(), FP),
("2026-06-05T11:59:58Z".to_string(), "DIFFERENT"),
],
);
let (_, streak, _, _) = read_prior_fires(&p, "sess", FP, now, 300);
assert_eq!(streak, 0, "a 2-second-old change still resets the streak");
}
#[test]
fn debounce_untimestamped_fire_is_transparent() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("events.jsonl");
let lines = [
r#"{"ts":"2026-06-05T11:40:00Z","type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
r#"{"ts":"not-a-timestamp","type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
r#"{"type":"loop_check","source":"hook","data":{"session_id":"sess","fingerprint":"FP"}}"#,
];
std::fs::write(&p, lines.join("\n") + "\n").unwrap();
let (_, streak, last_fp, _) = read_prior_fires(&p, "sess", FP, at(NOW), 300);
assert_eq!(
streak, 1,
"unplaceable fires skip; the good one still counts"
);
assert_eq!(
last_fp.as_deref(),
Some(FP),
"carry-forward still reads the newest recorded fp"
);
}
#[test]
fn parse_manifest_minimal() {
let content =
"---\nsession_id: abc\ncreated_at: 2026-06-05T00:00:00Z\nattended: true\n---\n";
let m = parse_manifest(content).unwrap();
assert_eq!(m.session_id.as_deref(), Some("abc"));
assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
assert!(m.attended);
assert!(m.legacy_status.is_none());
}
#[test]
fn scan_manifest_field_reads_claim_fields_after_frontmatter() {
let content = "---\nsession_id: s1\nattended: false\n---\n\
Immutable session manifest.\n\
target_claim_key: \"node:x-ba4b\"\n\
target_claim_holder: \"target-session:s1\"\n\
target_claim_ttl: \"2h\"\n";
let m = parse_manifest(content).unwrap();
assert_eq!(m.session_id.as_deref(), Some("s1"));
assert_eq!(
scan_manifest_field(content, "target_claim_key").as_deref(),
Some("node:x-ba4b")
);
assert_eq!(
scan_manifest_field(content, "target_claim_holder").as_deref(),
Some("target-session:s1")
);
assert_eq!(
scan_manifest_field(content, "target_claim_ttl")
.as_deref()
.and_then(crate::claims::parse_ttl_ms),
Some(7_200_000)
);
assert_eq!(scan_manifest_field(content, "nonexistent_field"), None);
}
#[test]
fn parse_manifest_legacy_complete() {
let content =
"---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: COMPLETE\n---\n";
let m = parse_manifest(content).unwrap();
assert_eq!(m.legacy_status.as_deref(), Some("COMPLETE"));
}
#[test]
fn parse_manifest_legacy_blocked() {
let content =
"---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nstatus: BLOCKED\n---\n";
let m = parse_manifest(content).unwrap();
assert_eq!(m.legacy_status.as_deref(), Some("BLOCKED"));
}
#[test]
fn parse_manifest_no_ship() {
let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nno_ship: true\n---\n";
let m = parse_manifest(content).unwrap();
assert!(m.no_ship);
assert!(!m.no_external);
}
#[test]
fn parse_manifest_planned() {
let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nplanned: true\n---\n";
let m = parse_manifest(content).unwrap();
assert!(m.planned);
assert!(!m.advisory); }
#[test]
fn parse_manifest_strips_quotes() {
let content = "---\nsession_id: \"s-quoted\"\ncreated_at: '2026-06-05T00:00:00Z'\n---\n";
let m = parse_manifest(content).unwrap();
assert_eq!(m.session_id.as_deref(), Some("s-quoted"));
assert_eq!(m.created_at.as_deref(), Some("2026-06-05T00:00:00Z"));
}
#[test]
fn parse_settings_nested_budget_and_ci() {
let cfg = "[budget.unattended]\ncost_cap_usd = 7.5\n\n[ci]\ndeclared_none = true\n";
let s = parse_settings(cfg);
assert_eq!(s.unattended_cost_cap_usd, Some(Ok(7.5)));
assert!(s.ci_declared_none);
}
#[test]
fn stderr_tail_multibyte_boundary_no_panic() {
let mut payload = String::new();
while payload.len() < 300 {
payload.push('\u{00e9}'); }
let tail = stderr_tail(payload.as_bytes());
assert!(tail.len() <= 200);
assert!(!tail.is_empty());
}
#[test]
fn parse_manifest_attended_default_true() {
let content = "---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\n---\n";
let m = parse_manifest(content).unwrap();
assert!(m.attended, "attended should default to true when absent");
}
#[test]
fn parse_manifest_budget_caps() {
let content =
"---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: 120\nbudget_cost_cap_usd: 5.0\n---\n";
let m = parse_manifest(content).unwrap();
assert_eq!(m.budget_wall_clock_cap_minutes, Some(Ok(120)));
assert_eq!(m.budget_cost_cap_usd, Some(Ok(5.0)));
}
#[test]
fn parse_manifest_no_frontmatter_returns_none() {
let content = "no frontmatter here";
assert!(parse_manifest(content).is_none());
}
#[test]
fn parse_settings_flat_budget_cap() {
let cfg = "budget_cap = 2.5\n";
let s = parse_settings(cfg);
assert_eq!(s.flat_budget_cap, Some(Ok(2.5)));
}
#[test]
fn parse_settings_nested_budget() {
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";
let s = parse_settings(cfg);
assert_eq!(s.attended_wall_cap_minutes, Some(Ok(90)));
assert_eq!(s.attended_cost_cap_usd, Some(Ok(10.0)));
assert_eq!(s.unattended_wall_cap_minutes, Some(Ok(60)));
assert_eq!(s.unattended_cost_cap_usd, Some(Ok(5.0)));
}
#[test]
fn parse_settings_ci_declared_none() {
let cfg = "[ci]\ndeclared_none = true\n";
let s = parse_settings(cfg);
assert!(s.ci_declared_none);
}
#[test]
fn parse_settings_comments_ignored() {
let cfg =
"# top comment\nbudget_cap = 1.0\n# another\n[ci]\n# inner\ndeclared_none = true\n";
let s = parse_settings(cfg);
assert_eq!(s.flat_budget_cap, Some(Ok(1.0)));
assert!(s.ci_declared_none);
}
#[test]
fn detect_intent_promise() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {"role": "assistant", "content": "done <promise>COMPLETE</promise>"}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
assert_eq!(detect_intent_full(&path), Intent::Promise);
}
#[test]
fn detect_intent_aborted_beats_promise() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {"role": "assistant", "content": "<aborted reason=\"user\">done</aborted>"}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
assert!(matches!(detect_intent_full(&path), Intent::Aborted { .. }));
}
#[test]
fn detect_intent_tool_result_ignored() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let user_line = serde_json::json!({
"message": {"role": "user", "content": "<promise>fake</promise>"}
});
std::fs::write(&path, serde_json::to_string(&user_line).unwrap() + "\n").unwrap();
assert_eq!(detect_intent_full(&path), Intent::None);
}
#[test]
fn detect_intent_none_when_no_assistant() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({"message": {"role": "user", "content": "go"}});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
assert_eq!(detect_intent_full(&path), Intent::None);
}
#[test]
fn detect_intent_array_content_blocks() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {
"role": "assistant",
"content": [
{"type": "text", "text": "<promise>done</promise>"},
{"type": "tool_use", "name": "Bash"}
]
}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
assert_eq!(detect_intent_full(&path), Intent::Promise);
}
#[test]
fn extract_last_assistant_message_plain_string() {
let payload = r#"{"transcript_path":"/t.jsonl","last_assistant_message":" done <promise>MISSION COMPLETE: x</promise> "}"#;
assert_eq!(
extract_last_assistant_message(payload).as_deref(),
Some("done <promise>MISSION COMPLETE: x</promise>")
);
}
#[test]
fn extract_last_assistant_message_degrades_to_none() {
assert_eq!(
extract_last_assistant_message(r#"{"transcript_path":"/t.jsonl"}"#),
None
);
assert_eq!(extract_last_assistant_message("not json {"), None);
assert_eq!(
extract_last_assistant_message(r#"{"last_assistant_message":{"text":"obj"}}"#),
None
);
assert_eq!(
extract_last_assistant_message(r#"{"last_assistant_message":" "}"#),
None
);
}
#[test]
fn detect_intent_payload_promise_wins_over_stale_transcript() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {"role": "assistant", "content": "still working on it"}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
let (intent, source) =
detect_intent(Some("<promise>MISSION COMPLETE: done</promise>"), &path);
assert_eq!(intent, Intent::Promise);
assert_eq!(source, "payload");
}
#[test]
fn detect_intent_payload_no_tag_is_authoritative() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {"role": "assistant", "content": "<promise>old stale promise</promise>"}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
let (intent, source) = detect_intent(Some("moving on to other work"), &path);
assert_eq!(intent, Intent::None);
assert_eq!(source, "payload");
}
#[test]
fn detect_intent_payload_aborted_beats_promise() {
let (intent, source) = detect_intent(
Some("<promise>done</promise> <aborted reason=\"kill\">stop</aborted>"),
Path::new("/nonexistent"),
);
assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
assert_eq!(source, "payload");
}
#[test]
fn watching_intent_parses_all_attrs() {
let (intent, source) = detect_intent(
Some("waiting <watching reason=\"ci\" pr=\"404\" timeout=\"30m\">"),
Path::new("/nonexistent"),
);
assert_eq!(source, "payload");
assert_eq!(
intent,
Intent::Watching {
reason: "ci".into(),
pr: Some("404".into()),
timeout: Some("30m".into()),
}
);
}
#[test]
fn watching_intent_malformed_attrs_default_to_absent() {
let (intent, _) = detect_intent(Some("<watching>"), Path::new("/nonexistent"));
assert_eq!(
intent,
Intent::Watching {
reason: String::new(),
pr: None,
timeout: None,
}
);
}
#[test]
fn watching_intent_aborted_beats_watching() {
let (intent, _) = detect_intent(
Some("<watching reason=\"ci\" pr=\"1\"> <aborted reason=\"kill\">"),
Path::new("/nonexistent"),
);
assert!(matches!(intent, Intent::Aborted { ref reason } if reason == "kill"));
}
#[test]
fn watching_intent_beats_promise() {
let (intent, _) = detect_intent(
Some("<promise>done</promise> <watching reason=\"review\" pr=\"9\">"),
Path::new("/nonexistent"),
);
assert!(matches!(intent, Intent::Watching { .. }));
}
#[test]
fn watching_intent_newest_transcript_entry_honored() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {"role": "assistant", "content": "<watching reason=\"ci\" pr=\"7\">"}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
assert!(matches!(detect_intent_full(&path), Intent::Watching { .. }));
}
#[test]
fn watching_intent_stale_transcript_not_honored() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let mut content = String::new();
for text in [
"<watching reason=\"ci\" pr=\"3\">", "still going",
"moving on to unrelated work", ] {
let line = serde_json::json!({"message": {"role": "assistant", "content": text}});
content.push_str(&serde_json::to_string(&line).unwrap());
content.push('\n');
}
std::fs::write(&path, content).unwrap();
assert_eq!(detect_intent_full(&path), Intent::None);
}
#[test]
fn watching_intent_stale_watch_does_not_shadow_deeper_promise() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let mut content = String::new();
for text in [
"<promise>MISSION COMPLETE: shipped</promise>", "<watching reason=\"ci\" pr=\"3\">", "tag-less newest", ] {
let line = serde_json::json!({"message": {"role": "assistant", "content": text}});
content.push_str(&serde_json::to_string(&line).unwrap());
content.push('\n');
}
std::fs::write(&path, content).unwrap();
assert_eq!(detect_intent_full(&path), Intent::Promise);
}
#[test]
fn detect_intent_absent_payload_falls_back_to_transcript() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let line = serde_json::json!({
"message": {"role": "assistant", "content": "<promise>COMPLETE</promise>"}
});
std::fs::write(&path, serde_json::to_string(&line).unwrap() + "\n").unwrap();
let (intent, source) = detect_intent(None, &path);
assert_eq!(intent, Intent::Promise);
assert_eq!(source, "transcript");
}
#[test]
fn detect_intent_lookback_finds_promise_behind_block_feedback() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let mut content = String::new();
for text in [
"<promise>MISSION COMPLETE: shipped</promise>",
"acknowledged the block; checking CI",
"CI is still pending, waiting",
] {
let line = serde_json::json!({
"message": {"role": "assistant", "content": text}
});
content.push_str(&serde_json::to_string(&line).unwrap());
content.push('\n');
}
std::fs::write(&path, content).unwrap();
assert_eq!(detect_intent_full(&path), Intent::Promise);
}
#[test]
fn detect_intent_lookback_bound_holds() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("t.jsonl");
let mut content = String::new();
let line = serde_json::json!({
"message": {"role": "assistant", "content": "<promise>stale</promise>"}
});
content.push_str(&serde_json::to_string(&line).unwrap());
content.push('\n');
for i in 0..INTENT_LOOKBACK_ENTRIES {
let line = serde_json::json!({
"message": {"role": "assistant", "content": format!("pivoted work step {i}")}
});
content.push_str(&serde_json::to_string(&line).unwrap());
content.push('\n');
}
std::fs::write(&path, content).unwrap();
assert_eq!(detect_intent_full(&path), Intent::None);
}
#[test]
fn parse_args_hook_input_stdin_flag() {
let args: Vec<String> = [
"loop-check",
"--state",
"/s.md",
"--transcript",
"/t.jsonl",
"--cwd",
"/w",
"--hook-input-stdin",
]
.iter()
.map(|s| s.to_string())
.collect();
let parsed = parse_args(&args).unwrap();
assert!(parsed.hook_input_stdin);
assert_eq!(parsed.cwd, PathBuf::from("/w"));
}
#[test]
fn block_reason_pending_ci_is_not_red() {
let pr = PrInfo {
state: PrState::Open,
number: 455,
head_oid: "abc".to_string(),
ci_conclusion: CiConclusion::Pending,
failing_checks: vec![],
ci_has_pending: false,
mergeable: "UNKNOWN".to_string(),
latest_review_ts: "none".to_string(),
reviewed: false,
missing_bots: vec![],
bot_nudges: vec![],
usage_limited: vec![],
unaddressed_findings: vec![],
review_skipped: false,
unattested_reviewers: vec![],
malformed_attestations: 0,
};
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("still running"),
"pending CI must not read as red; got: {reason}"
);
assert!(!reason.contains("failed"), "got: {reason}");
}
#[test]
fn unwatched_async_nudge_ci_pending_teaches_arm_and_tag() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Pending,
ci_has_pending: true,
..watch_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("<watching"), "got: {reason}");
assert!(reason.contains("timeout"), "got: {reason}");
assert!(reason.contains("gh pr checks"), "got: {reason}");
assert!(!reason.contains("wait silently"), "got: {reason}");
}
#[test]
fn no_hint_prescribes_the_timeout_binary() {
let needle = ["timeout", " "].concat();
for tail in include_str!("loopcheck.rs").split(&needle).skip(1) {
assert!(
!tail.trim_start().starts_with(|c: char| c.is_ascii_digit()),
"bare timeout invocation: ...{}",
tail.chars().take(60).collect::<String>()
);
}
}
#[test]
fn unwatched_async_nudge_missing_review_teaches_arm_and_tag() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Success,
ci_has_pending: false,
reviewed: false,
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
..watch_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("chatgpt-codex-connector"), "got: {reason}");
assert!(reason.contains("<watching"), "got: {reason}");
}
fn watch_pr() -> PrInfo {
PrInfo {
state: PrState::Open,
number: 404,
head_oid: "abc".to_string(),
ci_conclusion: CiConclusion::Pending,
failing_checks: vec![],
ci_has_pending: true,
mergeable: "UNKNOWN".to_string(),
latest_review_ts: "none".to_string(),
reviewed: false,
missing_bots: vec![],
bot_nudges: vec![],
usage_limited: vec![],
unaddressed_findings: vec![],
review_skipped: false,
unattested_reviewers: vec![],
malformed_attestations: 0,
}
}
#[test]
fn watch_idle_classifies_pending_ci() {
assert_eq!(async_wait_class(&watch_pr(), "abc", true), Some("ci"));
}
#[test]
fn codex_watch_harness_gate_is_claude_only() {
assert!(harness_can_idle(Some("claude"), false));
assert!(!harness_can_idle(Some("claude"), true));
assert!(!harness_can_idle(Some("codex"), false));
assert!(!harness_can_idle(Some("gemini"), false));
assert!(!harness_can_idle(None, false));
}
#[test]
fn watch_idle_classifies_awaiting_review() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Success,
ci_has_pending: false,
reviewed: false,
review_skipped: false,
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
..watch_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
}
#[test]
fn watch_idle_rejects_ci_pending_with_a_failure() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Failure(Some("unit".into())),
ci_has_pending: true,
..watch_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), None);
}
#[test]
fn watch_idle_rejects_local_attestation_review_gate() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Success,
ci_has_pending: false,
reviewed: false,
review_skipped: false,
missing_bots: vec![],
bot_nudges: vec![],
..watch_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), None);
}
fn bn(login: &str, class: NudgeClass, nudges: usize, newest: i64, span: i64) -> BotNudge {
BotNudge {
login: login.into(),
class,
review_handle: "@codex review".into(),
ceiling: 3,
nudges,
newest_age_min: newest,
span_min: span,
}
}
fn bot_review_pr(login: &str, nudges: Vec<BotNudge>) -> PrInfo {
PrInfo {
number: 618,
ci_conclusion: CiConclusion::Success,
ci_has_pending: false,
reviewed: false,
review_skipped: false,
missing_bots: vec![login.into()],
bot_nudges: nudges,
..watch_pr()
}
}
#[test]
fn nudge_needs_nudge_blocks_and_names_the_command() {
let pr = bot_review_pr(
"chatgpt-codex-connector",
vec![bn(
"chatgpt-codex-connector",
NudgeClass::NeedsNudge,
0,
0,
0,
)],
);
assert_eq!(async_wait_class(&pr, "abc", true), None);
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("gh pr comment 618 --body \"@codex review\""),
"{reason}"
);
assert!(
!reason.contains("harness-tracked watcher"),
"no arm hint: {reason}"
);
}
#[test]
fn nudge_awaiting_idles_with_the_arm_hint() {
let pr = bot_review_pr(
"chatgpt-codex-connector",
vec![bn("chatgpt-codex-connector", NudgeClass::Awaiting, 1, 3, 3)],
);
assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("nudged"), "{reason}");
assert!(reason.contains("awaiting"), "{reason}");
assert!(
reason.contains("harness-tracked watcher"),
"arm hint present: {reason}"
);
}
#[test]
fn nudge_unresponsive_blocks_and_names_optional_apps() {
let pr = bot_review_pr(
"chatgpt-codex-connector",
vec![bn(
"chatgpt-codex-connector",
NudgeClass::Unresponsive,
3,
20,
47,
)],
);
assert_eq!(async_wait_class(&pr, "abc", true), None);
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("did not review after 3 nudges over 47m"),
"{reason}"
);
assert!(reason.contains("config.review.optional_apps"), "{reason}");
assert!(reason.contains("do not arm a watcher"), "{reason}");
assert!(
!reason.contains("harness-tracked watcher"),
"no arm hint: {reason}"
);
}
#[test]
fn nudge_not_nudgeable_keeps_todays_behavior() {
let pr = bot_review_pr(
"gemini-code-assist",
vec![bn("gemini-code-assist", NudgeClass::NotNudgeable, 0, 0, 0)],
);
assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("gemini-code-assist has not reviewed"),
"{reason}"
);
assert!(
reason.contains("harness-tracked watcher"),
"arm hint present: {reason}"
);
}
#[test]
fn nudge_empty_classification_is_status_quo() {
let pr = bot_review_pr("chatgpt-codex-connector", vec![]);
assert_eq!(async_wait_class(&pr, "abc", true), Some("review"));
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("has not reviewed"), "{reason}");
}
#[test]
fn finding_block_reason_names_the_reply_handle() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Success,
ci_has_pending: false,
reviewed: false,
unaddressed_findings: vec![Finding {
id: 1,
author: "chatgpt-codex-connector".into(),
path: "a.rs".into(),
line: 10,
created_at: "2026-07-06T01:00:00Z".into(),
severity: "P1",
}],
..watch_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("@chatgpt-codex-connector"), "{reason}");
}
#[test]
fn nudge_post_is_suppressed_by_the_escape_hatch() {
std::env::set_var("FNO_LOOPCHECK_NO_COMMENT", "1");
let posted = post_nudge_comment(
"/nonexistent/gh",
std::path::Path::new("/tmp"),
618,
"@codex review",
);
std::env::remove_var("FNO_LOOPCHECK_NO_COMMENT");
assert!(!posted);
}
#[test]
fn unresponsive_bot_drives_the_giveup_message() {
let pr = bot_review_pr(
"chatgpt-codex-connector",
vec![bn(
"chatgpt-codex-connector",
NudgeClass::Unresponsive,
3,
20,
47,
)],
);
let n = unresponsive_bot(&pr).expect("an unresponsive bot");
let msg = nudge_giveup_message(n);
assert!(msg.contains("chatgpt-codex-connector"), "{msg}");
assert!(msg.contains("3 nudges over 47m"), "{msg}");
assert!(msg.contains("config.review.optional_apps"), "{msg}");
}
#[test]
fn no_giveup_for_an_awaiting_bot() {
let pr = bot_review_pr(
"chatgpt-codex-connector",
vec![bn("chatgpt-codex-connector", NudgeClass::Awaiting, 1, 3, 3)],
);
assert!(unresponsive_bot(&pr).is_none());
}
fn reviewers_gate_pr() -> PrInfo {
PrInfo {
ci_conclusion: CiConclusion::Success,
ci_has_pending: false,
reviewed: false,
review_skipped: false,
missing_bots: vec![],
bot_nudges: vec![],
unaddressed_findings: vec![],
unattested_reviewers: vec![UnattestedReviewer {
name: "sigma".to_string(),
superseded_head: None,
failed_at_head: false,
}],
..watch_pr()
}
}
#[test]
fn block_reason_names_the_reviewers_gate_not_a_bot() {
let reason = build_block_reason(&reviewers_gate_pr(), "abc", true);
assert!(reason.contains("reviewers gate unmet"), "got: {reason}");
assert!(reason.contains("sigma"), "got: {reason}");
assert!(reason.contains("/fno:review sigma"), "got: {reason}");
assert!(!reason.contains("bot reviewer"), "got: {reason}");
}
#[test]
fn block_reason_names_the_local_peer_invocation() {
let mut pr = reviewers_gate_pr();
pr.unattested_reviewers[0].name = LOCAL_PEER_REVIEWER.to_string();
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("/fno:review peer --attest"),
"got: {reason}"
);
assert!(
!reason.contains("wait on a GitHub reviewer"),
"got: {reason}"
);
}
#[test]
fn block_reason_explains_same_model_local_peer_refusal() {
let mut pr = reviewers_gate_pr();
pr.unattested_reviewers[0].name = SAME_MODEL_LOCAL_PEER_SENTINEL.to_string();
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("configure a cross-model peer"),
"got: {reason}"
);
assert!(
!reason.contains(SAME_MODEL_LOCAL_PEER_SENTINEL),
"got: {reason}"
);
}
#[test]
fn block_reason_reviewers_gate_emits_no_idle_ritual() {
let pr = reviewers_gate_pr();
assert_eq!(async_wait_class(&pr, "abc", true), None);
let reason = build_block_reason(&pr, "abc", true);
assert!(!reason.contains("<watching"), "got: {reason}");
assert!(
!reason.contains("Arm a harness-tracked watcher"),
"got: {reason}"
);
assert!(!reason.contains("gh pr checks"), "got: {reason}");
}
#[test]
fn block_reason_names_a_superseded_attestation_head() {
let pr = PrInfo {
unattested_reviewers: vec![UnattestedReviewer {
name: "sigma".to_string(),
superseded_head: Some("0123456789abcdef".to_string()),
failed_at_head: false,
}],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("01234567"), "got: {reason}");
assert!(reason.contains("superseded"), "got: {reason}");
}
#[test]
fn block_reason_generic_review_fallback_has_no_idle_ritual() {
let pr = PrInfo {
unattested_reviewers: vec![],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(!reason.contains("<watching"), "got: {reason}");
assert!(!reason.contains("bot reviewer"), "got: {reason}");
}
#[test]
fn block_reason_missing_bot_still_teaches_the_ritual() {
let pr = PrInfo {
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
unattested_reviewers: vec![],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("chatgpt-codex-connector"), "got: {reason}");
assert!(reason.contains("<watching"), "got: {reason}");
}
#[test]
fn block_reason_local_work_outranks_a_bot_wait() {
let pr = PrInfo {
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("reviewers gate unmet"), "got: {reason}");
assert!(!reason.contains("<watching"), "got: {reason}");
let after = PrInfo {
unattested_reviewers: vec![],
..pr
};
assert!(build_block_reason(&after, "abc", true).contains("<watching"));
}
#[test]
fn reviewers_gate_stays_fail_closed() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("absent.jsonl");
let sigma = vec!["sigma".to_string()];
assert!(!unattested_reviewers(&missing, &sigma, "h").is_empty());
let stale = tmp.path().join("stale.jsonl");
std::fs::write(
&stale,
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
)
.unwrap();
let out = unattested_reviewers(&stale, &sigma, "NEW");
assert_eq!(out.len(), 1);
assert_eq!(out[0].superseded_head.as_deref(), Some("OLD"));
assert!(!out[0].failed_at_head);
let failed = tmp.path().join("fail.jsonl");
std::fs::write(
&failed,
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
)
.unwrap();
let out = unattested_reviewers(&failed, &sigma, "h");
assert_eq!(out.len(), 1);
assert_eq!(out[0].superseded_head, None);
assert!(
out[0].failed_at_head,
"a fail at HEAD must be reported as such"
);
}
#[test]
fn unpinned_attestation_never_counts_as_evidence() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("e.jsonl");
std::fs::write(
&p,
r#"{"type":"review_attestation","data":{"reviewer":"sigma","verdict":"pass"}}"#,
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "");
assert_eq!(out.len(), 1, "unpinned evidence must not satisfy the gate");
assert_eq!(out[0].superseded_head, None);
}
#[test]
fn a_failed_old_head_is_not_reported_as_superseded() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("e.jsonl");
std::fs::write(
&p,
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
assert_eq!(out.len(), 1);
assert_eq!(out[0].superseded_head, None);
}
#[test]
fn a_corrupt_attestation_line_is_counted_and_named() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("e.jsonl");
std::fs::write(
&p,
concat!(
r#"{"type":"review_attestation","data":{"reviewer":"sigma","hea"#,
"\n",
r#"{"type":"loop_check","data":{}}"#,
),
)
.unwrap();
let (out, malformed) = unattested_reviewers_scan(&p, &["sigma".to_string()], "h");
assert_eq!(out.len(), 1, "a corrupt line never satisfies the gate");
assert_eq!(malformed, 1, "and it is counted, not silently dropped");
let pr = PrInfo {
malformed_attestations: malformed,
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(
reason.contains("unparseable attestation line"),
"got: {reason}"
);
std::fs::write(&p, r#"{"type":"loop_check","data":{}}"#).unwrap();
assert_eq!(
unattested_reviewers_scan(&p, &["sigma".to_string()], "h").1,
0
);
assert!(!build_block_reason(&reviewers_gate_pr(), "abc", true)
.contains("unparseable attestation line"));
}
#[test]
fn a_revoked_pass_falls_back_to_an_older_passing_head() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("e.jsonl");
let line = |head: &str, verdict: &str| {
format!(
r#"{{"type":"review_attestation","data":{{"reviewer":"sigma","head_sha":"{head}","verdict":"{verdict}"}}}}"#
)
};
std::fs::write(
&p,
[
line("AAA", "pass"),
line("BBB", "pass"),
line("BBB", "fail"),
]
.join("\n"),
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].superseded_head.as_deref(),
Some("AAA"),
"a still-valid older pass must survive a newer head's retraction"
);
std::fs::write(&p, [line("AAA", "pass"), line("BBB", "pass")].join("\n")).unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
assert_eq!(out[0].superseded_head.as_deref(), Some("BBB"));
std::fs::write(
&p,
[
line("AAA", "pass"),
line("BBB", "pass"),
line("BBB", "fail"),
line("AAA", "fail"),
]
.join("\n"),
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "CCC");
assert_eq!(out[0].superseded_head, None);
}
#[test]
fn a_later_fail_revokes_the_superseded_pass_for_that_head() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("e.jsonl");
std::fs::write(
&p,
concat!(
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
"\n",
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
),
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].superseded_head, None,
"a retracted pass is not evidence"
);
std::fs::write(
&p,
concat!(
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
"\n",
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"fail"}}"#,
"\n",
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
),
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
assert_eq!(out[0].superseded_head.as_deref(), Some("OLD"));
}
#[test]
fn short_sha_never_panics_on_multibyte() {
assert_eq!(short_sha("0123456789ab"), "01234567");
assert_eq!(short_sha("abc"), "abc");
assert_eq!(short_sha(""), "");
assert_eq!(short_sha("1234567\u{e9}xyz"), "1234567\u{e9}");
let pr = PrInfo {
unattested_reviewers: vec![UnattestedReviewer {
name: "sigma".to_string(),
superseded_head: Some("1234567\u{e9}abc".to_string()),
failed_at_head: false,
}],
..reviewers_gate_pr()
};
build_block_reason(&pr, "1234567\u{e9}abc", true);
}
#[test]
fn watcher_hint_never_contradicts_the_idle_classifier() {
let bot_only = PrInfo {
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
unattested_reviewers: vec![],
..reviewers_gate_pr()
};
for (label, pr, open_empty) in [
(
"bot + unaddressed finding (renders as the finding)",
PrInfo {
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
unattested_reviewers: vec![],
unaddressed_findings: vec![Finding {
id: 1,
author: "codex".into(),
path: "a.rs".into(),
line: 1,
created_at: "2026-07-27T00:00:00Z".into(),
severity: "P1",
}],
..reviewers_gate_pr()
},
true,
),
(
"bot + open operator finding",
PrInfo {
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
unattested_reviewers: vec![],
..reviewers_gate_pr()
},
false,
),
] {
let reason = build_block_reason(&pr, "abc", open_empty);
assert_eq!(async_wait_class(&pr, "abc", open_empty), None, "{label}");
assert!(!reason.contains("<watching"), "{label}: {reason}");
}
assert_eq!(async_wait_class(&bot_only, "abc", true), Some("review"));
assert!(build_block_reason(&bot_only, "abc", true).contains("<watching"));
}
#[test]
fn an_unaddressed_finding_is_named_before_the_reviewers_gate() {
let pr = PrInfo {
unaddressed_findings: vec![Finding {
id: 1,
author: "codex".into(),
path: "a.rs".into(),
line: 7,
created_at: "2026-07-27T00:00:00Z".into(),
severity: "P1",
}],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("unaddressed"), "got: {reason}");
assert!(!reason.contains("reviewers gate unmet"), "got: {reason}");
let after = PrInfo {
unaddressed_findings: vec![],
..pr
};
assert!(build_block_reason(&after, "abc", true).contains("reviewers gate unmet"));
}
#[test]
fn a_failed_attestation_at_this_head_is_not_reported_as_absent() {
let pr = PrInfo {
unattested_reviewers: vec![UnattestedReviewer {
name: "sigma".to_string(),
superseded_head: None,
failed_at_head: true,
}],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("verdict NOT pass"), "got: {reason}");
}
#[test]
fn the_stop_gate_marks_declare_as_a_self_cert() {
let pr = PrInfo {
unattested_reviewers: vec![UnattestedReviewer {
name: "declare".to_string(),
superseded_head: None,
failed_at_head: false,
}],
..reviewers_gate_pr()
};
let reason = build_block_reason(&pr, "abc", true);
assert!(reason.contains("self-cert"), "got: {reason}");
assert!(
reason.contains("asserts no review evidence"),
"got: {reason}"
);
assert!(!build_block_reason(&reviewers_gate_pr(), "abc", true).contains("self-cert"));
}
#[test]
fn an_empty_head_sha_never_becomes_a_superseded_head() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("e.jsonl");
std::fs::write(
&p,
r#"{"type":"review_attestation","data":{"reviewer":"sigma","head_sha":"","verdict":"pass"}}"#,
)
.unwrap();
let out = unattested_reviewers(&p, &["sigma".to_string()], "NEW");
assert_eq!(out.len(), 1);
assert_eq!(out[0].superseded_head, None);
}
#[test]
fn an_outstanding_local_reviewer_is_never_an_idlable_wait() {
let pr = PrInfo {
missing_bots: vec!["chatgpt-codex-connector".into()],
bot_nudges: vec![],
..reviewers_gate_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), None);
}
#[test]
fn reviewer_invocations_cover_the_descriptor_table() {
for (name, inv, self_cert) in REVIEWER_INVOCATIONS {
assert!(!inv.is_empty(), "{name} has no invocation");
assert_eq!(reviewer_invocation(name), Some((*inv, *self_cert)));
}
assert_eq!(reviewer_invocation("teleport"), None);
assert_eq!(reviewer_invocation("declare").map(|(_, sc)| sc), Some(true));
assert_eq!(reviewer_invocation("sigma").map(|(_, sc)| sc), Some(false));
}
#[test]
fn unwatched_async_nudge_review_uses_review_aware_watcher() {
let hint = arm_watch_hint(404, "review");
assert!(hint.contains("--json reviews"), "got: {hint}");
assert!(!hint.contains("gh pr checks"), "got: {hint}");
let ci_hint = arm_watch_hint(404, "ci");
assert!(ci_hint.contains("gh pr checks"), "got: {ci_hint}");
}
#[test]
fn watch_idle_rejects_head_mismatch() {
assert_eq!(async_wait_class(&watch_pr(), "def", true), None);
}
#[test]
fn watch_idle_rejects_ci_red() {
let pr = PrInfo {
ci_conclusion: CiConclusion::Failure(Some("unit".into())),
ci_has_pending: false,
..watch_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), None);
}
#[test]
fn watch_idle_rejects_unaddressed_finding() {
let pr = PrInfo {
unaddressed_findings: vec![Finding {
id: 1,
author: "codex".into(),
path: "a.rs".into(),
line: 1,
created_at: "none".into(),
severity: "P1",
}],
..watch_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), None);
}
#[test]
fn watch_idle_rejects_open_operator_finding() {
assert_eq!(async_wait_class(&watch_pr(), "abc", false), None);
}
#[test]
fn watch_idle_rejects_non_open_pr() {
let pr = PrInfo {
state: PrState::Merged,
..watch_pr()
};
assert_eq!(async_wait_class(&pr, "abc", true), None);
}
#[test]
fn watch_idle_window_defaults_clamps_and_slacks() {
assert_eq!(watch_window_ms(None), 30 * 60_000 + WATCH_SLACK_MS);
assert_eq!(watch_window_ms(Some("30m")), 30 * 60_000 + WATCH_SLACK_MS);
assert_eq!(watch_window_ms(Some("1m")), 5 * 60_000 + WATCH_SLACK_MS);
assert_eq!(watch_window_ms(Some("5h")), 2 * 3_600_000 + WATCH_SLACK_MS);
assert_eq!(watch_window_ms(Some("soon")), 30 * 60_000 + WATCH_SLACK_MS);
}
#[test]
fn fingerprint_format() {
let fp = make_fingerprint("sha123", "OPEN", "SUCCESS", "2026-06-05T01:00:00Z");
assert_eq!(fp, "sha123|OPEN|SUCCESS|2026-06-05T01:00:00Z");
}
#[test]
fn ci_conclusion_failure_extracts_name() {
let checks = serde_json::json!([
{"name": "unit-tests", "state": "FAILURE", "bucket": "fail"}
]);
let result = compute_ci_conclusion(&checks).unwrap();
assert_eq!(
result,
CiConclusion::Failure(Some("unit-tests".to_string()))
);
let rendered = result.render();
assert!(rendered.starts_with("FAILURE:"), "got: {rendered}");
assert!(rendered.contains("unit-tests"), "got: {rendered}");
}
#[test]
fn ci_conclusion_cancel_is_failure() {
let checks = serde_json::json!([
{"name": "ci", "state": "SUCCESS", "bucket": "pass"},
{"name": "deploy", "state": "CANCELLED", "bucket": "cancel"}
]);
assert_eq!(
compute_ci_conclusion(&checks).unwrap(),
CiConclusion::Failure(Some("deploy".to_string()))
);
}
#[test]
fn ci_conclusion_bucket_vocabulary() {
let green = serde_json::json!([
{"name": "ci", "state": "SUCCESS", "bucket": "pass"},
{"name": "publish", "state": "SKIPPED", "bucket": "skipping"}
]);
assert_eq!(
compute_ci_conclusion(&green).unwrap(),
CiConclusion::Success
);
let pending = serde_json::json!([
{"name": "ci", "state": "SUCCESS", "bucket": "pass"},
{"name": "smoke", "state": "IN_PROGRESS", "bucket": "pending"}
]);
assert_eq!(
compute_ci_conclusion(&pending).unwrap(),
CiConclusion::Pending
);
}
#[test]
fn ci_conclusion_unknown_bucket_fails_closed() {
let unknown = serde_json::json!([
{"name": "ci", "state": "SUCCESS", "bucket": "mystery"}
]);
assert_eq!(
compute_ci_conclusion(&unknown).unwrap(),
CiConclusion::Pending
);
let missing = serde_json::json!([{"name": "ci", "state": "SUCCESS"}]);
assert_eq!(
compute_ci_conclusion(&missing).unwrap(),
CiConclusion::Pending
);
}
#[test]
fn ci_conclusion_empty_returns_none() {
let checks = serde_json::json!([]);
let result = compute_ci_conclusion(&checks).unwrap();
assert_eq!(result, CiConclusion::None);
assert_eq!(result.render(), "none");
}
#[test]
fn ci_conclusion_all_success() {
let checks = serde_json::json!([
{"name": "ci", "state": "SUCCESS", "bucket": "pass"}
]);
let result = compute_ci_conclusion(&checks).unwrap();
assert_eq!(result, CiConclusion::Success);
assert_eq!(result.render(), "SUCCESS");
}
#[test]
fn failing_check_names_collects_fail_and_cancel_only() {
let checks = serde_json::json!([
{"name": "smoke", "bucket": "fail"},
{"name": "loc-ratchet", "bucket": "pass"},
{"name": "prompt-drift", "bucket": "cancel"},
{"name": "self-test", "bucket": "pending"},
{"name": "doc-colo", "bucket": "skipping"},
]);
let mut got = failing_check_names(&checks);
got.sort();
assert_eq!(got, vec!["prompt-drift".to_string(), "smoke".to_string()]);
}
#[test]
fn failing_check_names_empty_when_all_green() {
let checks = serde_json::json!([{"name": "smoke", "bucket": "pass"}]);
assert!(failing_check_names(&checks).is_empty());
assert!(failing_check_names(&serde_json::json!({})).is_empty());
}
#[test]
fn ci_has_pending_gates_partial_ci() {
let partial = serde_json::json!([
{"name": "smoke", "bucket": "fail"},
{"name": "rust-ci", "bucket": "pending"},
]);
assert!(ci_has_pending_checks(&partial));
let settled = serde_json::json!([
{"name": "smoke", "bucket": "fail"},
{"name": "rust-ci", "bucket": "pass"},
{"name": "doc", "bucket": "skipping"},
]);
assert!(!ci_has_pending_checks(&settled));
let unknown = serde_json::json!([{"name": "x", "bucket": "queued"}]);
assert!(ci_has_pending_checks(&unknown));
assert!(!ci_has_pending_checks(&serde_json::json!({})));
}
#[test]
fn parse_failing_run_ids_only_failures_on_head_sha() {
let list = serde_json::json!([
{"databaseId": 1, "conclusion": "failure", "headSha": "head"},
{"databaseId": 2, "conclusion": "success", "headSha": "head"},
{"databaseId": 3, "conclusion": "cancelled", "headSha": "head"},
{"databaseId": 4, "conclusion": "failure", "headSha": "old"},
{"databaseId": 5, "conclusion": "failure", "headSha": "head"},
]);
assert_eq!(parse_failing_run_ids(&list, "head"), vec![1, 5]);
assert_eq!(parse_failing_run_ids(&list, "old"), vec![4]);
}
#[test]
fn parse_failing_job_names_only_failed_jobs() {
let view = serde_json::json!({
"jobs": [
{"name": "codex", "conclusion": "success"},
{"name": "cargo test + schema parity", "conclusion": "failure"},
{"name": "gemini", "conclusion": "failure"},
]
});
let mut got = parse_failing_job_names(&view);
got.sort();
assert_eq!(
got,
vec![
"cargo test + schema parity".to_string(),
"gemini".to_string()
]
);
assert!(parse_failing_job_names(&serde_json::json!({})).is_empty());
}
#[test]
fn subset_rule_pr_failing_is_covered_by_main() {
let pr = vec!["cargo test + schema parity".to_string()];
let main = vec![
"cargo test + schema parity".to_string(),
"some other main-only red".to_string(),
];
assert!(is_pre_existing_main_red(&pr, &main));
}
#[test]
fn subset_rule_pr_unique_red_blocks() {
let pr = vec![
"cargo test + schema parity".to_string(),
"fmt gate".to_string(), ];
let main = vec!["cargo test + schema parity".to_string()];
assert!(!is_pre_existing_main_red(&pr, &main));
}
#[test]
fn subset_rule_empty_pr_failing_never_eligible() {
assert!(!is_pre_existing_main_red(&[], &["x".to_string()]));
assert!(!is_pre_existing_main_red(&["x".to_string()], &[]));
}
#[test]
fn already_emitted_awaiting_merge_detects_prior_and_absence() {
let dir = tempfile::tempdir().unwrap();
let events = dir.path().join("events.jsonl");
assert!(!already_emitted_awaiting_merge(&events, "sess-A"));
std::fs::write(
&events,
"{\"type\":\"termination\",\"data\":{\"session_id\":\"sess-A\",\"reason\":\"DonePRGreen\"}}\n",
)
.unwrap();
assert!(!already_emitted_awaiting_merge(&events, "sess-A"));
std::fs::write(
&events,
"{\"type\":\"termination\",\"data\":{\"session_id\":\"sess-A\",\"reason\":\"DoneAwaitingMerge\"}}\n",
)
.unwrap();
assert!(already_emitted_awaiting_merge(&events, "sess-A"));
assert!(!already_emitted_awaiting_merge(&events, "sess-B"));
}
#[test]
fn pr_state_parses_known_gh_strings() {
assert_eq!(PrState::from_gh_str("OPEN"), PrState::Open);
assert_eq!(PrState::from_gh_str("MERGED"), PrState::Merged);
assert_eq!(PrState::from_gh_str("CLOSED"), PrState::Closed);
assert_eq!(PrState::from_gh_str("none"), PrState::None);
}
#[test]
fn pr_state_unknown_string_fails_closed() {
assert_eq!(PrState::from_gh_str("DRAFT"), PrState::None);
assert_eq!(PrState::from_gh_str(""), PrState::None);
assert_eq!(PrState::from_gh_str("open"), PrState::None);
}
#[test]
fn enum_rendering_byte_identical_to_legacy_strings() {
assert_eq!(PrState::Open.as_str(), "OPEN");
assert_eq!(PrState::Merged.as_str(), "MERGED");
assert_eq!(PrState::Closed.as_str(), "CLOSED");
assert_eq!(PrState::None.as_str(), "none");
assert_eq!(CiConclusion::Success.render(), "SUCCESS");
assert_eq!(
CiConclusion::Failure(Some("lint".into())).render(),
"FAILURE:lint"
);
assert_eq!(CiConclusion::Failure(None).render(), "FAILURE");
assert_eq!(CiConclusion::Pending.render(), "PENDING");
assert_eq!(CiConclusion::Skipped.render(), "skipped");
assert_eq!(CiConclusion::None.render(), "none");
}
#[test]
fn parse_args_missing_required_flags_err() {
let no_state: Vec<String> = vec![
"loop-check".into(),
"--transcript".into(),
"/t".into(),
"--cwd".into(),
"/c".into(),
];
assert_eq!(
parse_args(&no_state).unwrap_err(),
"--state is required".to_string()
);
let no_transcript: Vec<String> = vec!["loop-check".into(), "--state".into(), "/s".into()];
assert_eq!(
parse_args(&no_transcript).unwrap_err(),
"--transcript is required".to_string()
);
let no_cwd: Vec<String> = vec![
"loop-check".into(),
"--state".into(),
"/s".into(),
"--transcript".into(),
"/t".into(),
];
assert_eq!(
parse_args(&no_cwd).unwrap_err(),
"--cwd is required".to_string()
);
}
#[test]
fn parse_args_unknown_flag_tolerated() {
let args: Vec<String> = vec![
"loop-check".into(),
"--state".into(),
"/s".into(),
"--transcript".into(),
"/t".into(),
"--cwd".into(),
"/c".into(),
"--future-flag=whatever".into(),
"--another-unknown".into(),
"value".into(),
];
let parsed = parse_args(&args).expect("unknown flags must be ignored");
assert_eq!(parsed.state_path, PathBuf::from("/s"));
assert_eq!(parsed.transcript_path, PathBuf::from("/t"));
assert_eq!(parsed.cwd, PathBuf::from("/c"));
}
#[test]
fn budget_flat_key_enforces_cost_cap_ab41b13d9d() {
let settings_cfg = "budget_cap = 0.10\n";
let settings = parse_settings(settings_cfg);
assert_eq!(settings.flat_budget_cap, Some(Ok(0.10)));
assert!(settings.attended_cost_cap_usd.is_none());
assert!(settings.unattended_cost_cap_usd.is_none());
let manifest_att = Manifest {
session_id: Some("s1".into()),
created_at: Some("2026-06-05T00:00:00Z".into()),
attended: true,
..Default::default()
};
let manifest_unatt = Manifest {
session_id: Some("s1".into()),
created_at: Some("2026-06-05T00:00:00Z".into()),
attended: false,
..Default::default()
};
let tmp = tempfile::tempdir().unwrap();
let ledger = tmp.path().join("ledger.json");
std::fs::write(&ledger, r#"[{"session_id":"s1","cost_usd":0.50}]"#).unwrap();
let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
assert_eq!(
check_budget(&manifest_att, &settings, &now, &ledger),
Some(BudgetTrip::Cost),
"flat budget_cap must enforce for attended"
);
assert_eq!(
check_budget(&manifest_unatt, &settings, &now, &ledger),
Some(BudgetTrip::Cost),
"flat budget_cap must enforce for unattended"
);
}
#[test]
fn is_bot_reviewer_known_patterns() {
assert!(is_bot_reviewer("gemini-code-assist[bot]", &[]));
assert!(is_bot_reviewer("chatgpt-codex-connector", &[]));
assert!(is_bot_reviewer("some-bot[bot]", &[]));
assert!(!is_bot_reviewer("human-reviewer", &[]));
}
#[test]
fn is_bot_reviewer_with_external_list() {
let external = vec!["my-bot".to_string()];
assert!(is_bot_reviewer("my-bot", &external));
assert!(is_bot_reviewer("other-bot[bot]", &external));
}
#[test]
fn session_cost_from_ledger_sums_session_only() {
let tmp = tempfile::tempdir().unwrap();
let ledger = tmp.path().join("l.json");
std::fs::write(
&ledger,
r#"[{"session_id":"a","cost_usd":1.0},{"session_id":"b","cost_usd":0.5},{"session_id":"a","cost_usd":0.25}]"#,
)
.unwrap();
let cost = session_cost_from_ledger(&ledger, "a");
assert!((cost - 1.25).abs() < 0.001, "expected 1.25, got {cost}");
}
#[test]
fn session_cost_missing_ledger_returns_zero() {
let cost = session_cost_from_ledger(Path::new("/nonexistent/l.json"), "s");
assert_eq!(cost, 0.0);
}
#[test]
fn allow_output_serializes_correctly() {
let json = allow_output(
"allow",
Some(TerminationReason::DonePRGreen),
"done",
3,
Some("fp".into()),
);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["decision"], "allow");
assert_eq!(v["termination_reason"], "DonePRGreen");
assert_eq!(v["fires"], 3);
assert_eq!(v["fingerprint"], "fp");
}
#[test]
fn allow_output_null_termination_reason() {
let json = allow_output("block", None, "continue", 1, None);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v["termination_reason"].is_null());
assert!(v["fingerprint"].is_null());
}
#[test]
fn watch_idle_event_is_non_terminal_allow() {
let json = allow_output(
"allow",
None,
"watching: idling until watcher fires (PR #404, ci pending)",
3,
Some("sha|OPEN|PENDING|none".to_string()),
);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["decision"], "allow");
assert!(
v["termination_reason"].is_null(),
"idle-allow MUST be non-terminal or finalize would run"
);
assert!(v["message"].as_str().unwrap().contains("watching"));
}
#[test]
fn termination_reason_variant_names_byte_identical() {
let cases = [
(TerminationReason::DonePRGreen, "DonePRGreen"),
(TerminationReason::DoneAdvisory, "DoneAdvisory"),
(TerminationReason::NoWork, "NoWork"),
(TerminationReason::Budget, "Budget"),
(TerminationReason::NoProgress, "NoProgress"),
(TerminationReason::Interrupted, "Interrupted"),
(TerminationReason::Aborted, "Aborted"),
];
for (variant, expected) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(
json,
format!("\"{expected}\""),
"variant {expected} serialized incorrectly"
);
}
}
#[test]
fn manifest_default_attended_is_true() {
let m = Manifest::default();
assert!(m.attended, "Manifest::default() must have attended=true");
assert!(!m.advisory);
assert!(!m.no_ship);
assert!(!m.no_external);
assert!(m.session_id.is_none());
assert!(m.budget_cost_cap_usd.is_none());
assert!(m.budget_wall_clock_cap_minutes.is_none());
}
#[test]
fn parse_manifest_malformed_cost_cap_fail_closed() {
let content =
"---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_cost_cap_usd: 5.OO\n---\n";
let m = parse_manifest(content).unwrap();
assert!(
matches!(m.budget_cost_cap_usd, Some(Err(_))),
"malformed cost cap must be Some(Err(...))"
);
}
#[test]
fn parse_manifest_malformed_wall_cap_fail_closed() {
let content =
"---\nsession_id: s\ncreated_at: 2026-06-05T00:00:00Z\nbudget_wall_clock_cap_minutes: abc\n---\n";
let m = parse_manifest(content).unwrap();
assert!(
matches!(m.budget_wall_clock_cap_minutes, Some(Err(_))),
"malformed wall cap must be Some(Err(...))"
);
}
#[test]
fn parse_settings_malformed_flat_cap_fail_closed() {
let cfg = "budget_cap = \"not_a_number\"\n";
let s = parse_settings(cfg);
assert!(
matches!(s.flat_budget_cap, Some(Err(_))),
"malformed flat_budget_cap must be Some(Err(...))"
);
}
#[test]
fn check_budget_malformed_cost_cap_trips_budget() {
let m = Manifest {
session_id: Some("s".into()),
created_at: Some("2026-06-05T00:00:00Z".into()),
budget_cost_cap_usd: Some(Err("5.OO".into())),
..Default::default()
};
let s = Settings::default();
let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
let tmp = tempfile::tempdir().unwrap();
let ledger = tmp.path().join("ledger.json");
std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":0.0}]"#).unwrap();
assert_eq!(
check_budget(&m, &s, &now, &ledger),
Some(BudgetTrip::Cost),
"malformed cost cap must fail closed"
);
}
#[test]
fn check_budget_absent_cap_is_unlimited() {
let m = Manifest {
session_id: Some("s".into()),
created_at: Some("2026-06-05T00:00:00Z".into()),
..Default::default()
};
let s = Settings::default();
let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
let tmp = tempfile::tempdir().unwrap();
let ledger = tmp.path().join("ledger.json");
std::fs::write(&ledger, r#"[{"session_id":"s","cost_usd":9999.0}]"#).unwrap();
assert_eq!(
check_budget(&m, &s, &now, &ledger),
None,
"absent cap must be unlimited"
);
}
#[test]
fn check_budget_negative_elapsed_no_trip() {
let m = Manifest {
session_id: Some("s".into()),
created_at: Some("2026-06-05T02:00:00Z".into()),
budget_wall_clock_cap_minutes: Some(Ok(30)),
..Default::default()
};
let s = Settings::default();
let now: DateTime<Utc> = "2026-06-05T01:00:00Z".parse().unwrap();
let tmp = tempfile::tempdir().unwrap();
let ledger = tmp.path().join("ledger.json");
std::fs::write(&ledger, "[]").unwrap();
assert_eq!(
check_budget(&m, &s, &now, &ledger),
None,
"negative elapsed (future created_at) must not trip wall clock cap"
);
}
#[test]
fn is_bot_reviewer_configured_short_names_match_real_logins() {
let external = vec!["gemini".to_string(), "codex".to_string()];
assert!(
is_bot_reviewer("gemini-code-assist[bot]", &external),
"gemini short name must substring-match gemini-code-assist[bot]"
);
assert!(
is_bot_reviewer("chatgpt-codex-connector", &external),
"codex short name must substring-match chatgpt-codex-connector"
);
}
#[test]
fn is_bot_reviewer_configured_list_falls_back_to_bot_heuristic() {
let external = vec!["some-human".to_string()];
assert!(
is_bot_reviewer("gemini-code-assist[bot]", &external),
"configured list with no match must still fall back to [bot] heuristic"
);
}
#[test]
fn is_bot_reviewer_empty_config_human_only_returns_false() {
assert!(
!is_bot_reviewer("alice-the-human", &[]),
"human reviewer with empty config must return false"
);
}
#[test]
fn parse_settings_required_bots_block_list() {
let cfg = "[review]\nrequired_bots = [\n \"chatgpt-codex-connector\",\n \"gemini-code-assist\",\n]\n";
let s = parse_settings(cfg);
assert_eq!(
s.required_bots,
Some(vec![
"chatgpt-codex-connector".to_string(),
"gemini-code-assist".to_string()
])
);
}
#[test]
fn parse_settings_required_bots_inline_empty_is_declared_empty() {
let cfg = "[review]\nrequired_bots = []\n";
let s = parse_settings(cfg);
assert_eq!(s.required_bots, Some(Vec::new()));
}
#[test]
fn parse_settings_required_bots_inline_list() {
let cfg = "[review]\nrequired_bots = [\"codex\", \"gemini\"]\n";
let s = parse_settings(cfg);
assert_eq!(
s.required_bots,
Some(vec!["codex".to_string(), "gemini".to_string()])
);
}
#[test]
fn parse_settings_required_bots_scalar_is_singleton() {
let cfg = "[review]\nrequired_bots = \"gemini\"\n";
let s = parse_settings(cfg);
assert_eq!(s.required_bots, Some(vec!["gemini".to_string()]));
let g = parse_settings("[review]\ngithub_apps = \"chatgpt-codex-connector\"\n");
assert_eq!(
g.github_apps,
Some(vec!["chatgpt-codex-connector".to_string()])
);
}
#[test]
fn parse_settings_absent_required_bots_defaults() {
let cfg = "[review]\ngithub_apps = []\n\n[ci]\ndeclared_none = true\n";
let s = parse_settings(cfg);
assert_eq!(
s.required_bots, None,
"absent key resolves to the no-gate default"
);
assert!(s.ci_declared_none, "following blocks still parse");
}
#[test]
fn parse_settings_required_bots_inline_comments_stripped() {
let empty = parse_settings("[review]\nrequired_bots = [] # no review gate\n");
assert_eq!(empty.required_bots, Some(Vec::new()));
let inline =
parse_settings("[review]\nrequired_bots = [\"chatgpt-codex-connector\"] # required\n");
assert_eq!(
inline.required_bots,
Some(vec!["chatgpt-codex-connector".to_string()])
);
let block = parse_settings(
"[review]\nrequired_bots = [ # the gate\n \"chatgpt-codex-connector\", # codex\n]\n",
);
assert_eq!(
block.required_bots,
Some(vec!["chatgpt-codex-connector".to_string()])
);
let scalar = parse_settings("[review]\nrequired_bots = \"gemini\" # oops\n");
assert_eq!(scalar.required_bots, Some(vec!["gemini".to_string()]));
}
#[test]
fn parse_settings_required_bots_multiline_array() {
let cfg = "[review]\nrequired_bots = [\n \"chatgpt-codex-connector\",\n]\n";
let s = parse_settings(cfg);
assert_eq!(
s.required_bots,
Some(vec!["chatgpt-codex-connector".to_string()])
);
}
#[test]
fn parse_settings_required_bots_reads_under_review_table() {
let cfg = "[review]\nrequired_bots = [\"chatgpt-codex-connector\"]\n";
let s = parse_settings(cfg);
assert_eq!(
s.required_bots,
Some(vec!["chatgpt-codex-connector".to_string()])
);
}
#[test]
fn parse_settings_malformed_fails_closed_not_zeroed() {
let cfg = "[review\nrequired_bots = []\n";
assert!(
parse_settings_result(cfg).is_err(),
"malformed TOML must be a parse error"
);
let s = parse_settings(cfg);
assert_eq!(
s.required_bots,
Some(vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]),
"a malformed file must fail closed, not zero the gate"
);
assert!(!login_matches_bot(
"chatgpt-codex-connector",
UNPARSEABLE_SETTINGS_SENTINEL
));
}
#[test]
fn parse_settings_unparseable_fails_closed() {
let cfg = "[review]\nrequired_bots = [1, 2, 3\n"; assert!(parse_settings_result(cfg).is_err());
let s = parse_settings(cfg);
assert_eq!(
resolved_required_bots(&s),
vec![UNPARSEABLE_SETTINGS_SENTINEL.to_string()]
);
}
#[test]
fn resolved_required_bots_default_is_empty() {
let s = Settings::default();
assert!(
resolved_required_bots(&s).is_empty(),
"absent required_bots config must resolve to no review gate"
);
}
#[test]
fn resolved_required_bots_explicit_list_wins() {
let s = Settings {
required_bots: Some(vec!["my-bot".to_string()]),
..Default::default()
};
assert_eq!(resolved_required_bots(&s), vec!["my-bot".to_string()]);
let empty = Settings {
required_bots: Some(Vec::new()),
..Default::default()
};
assert!(resolved_required_bots(&empty).is_empty());
}
#[test]
fn parse_settings_structural_scalar_degrades_like_python() {
assert_eq!(scalar_as_singleton(" {login: codex}"), None);
assert_eq!(scalar_as_singleton(" 123"), Some(vec!["123".to_string()]));
let g = parse_settings("[review]\ngithub_apps = {login = \"codex\"}\n");
assert_eq!(g.github_apps, None, "an inline table is not a login gate");
let o = parse_settings("[review]\noptional_apps = {a = \"b\"}\n");
assert_eq!(o.optional_apps, None);
}
#[test]
fn parse_settings_optional_apps_forms() {
let inline = parse_settings("[review]\noptional_apps = [\"chatgpt-codex-connector\"]\n");
assert_eq!(
inline.optional_apps,
Some(vec!["chatgpt-codex-connector".to_string()])
);
let block =
parse_settings("[review]\noptional_apps = [\n \"chatgpt-codex-connector\",\n]\n");
assert_eq!(
block.optional_apps,
Some(vec!["chatgpt-codex-connector".to_string()])
);
let scalar = parse_settings("[review]\noptional_apps = \"chatgpt-codex-connector\"\n");
assert_eq!(
scalar.optional_apps,
Some(vec!["chatgpt-codex-connector".to_string()])
);
}
#[test]
fn parse_settings_reviewers_forms() {
let inline = parse_settings("[review]\nreviewers = [\"sigma\", \"/code-review\"]\n");
assert_eq!(
inline.reviewers,
vec!["sigma".to_string(), "code-review".to_string()]
);
let block = parse_settings("[review]\nreviewers = [\n \"sigma\",\n]\n");
assert_eq!(block.reviewers, vec!["sigma".to_string()]);
let scalar = parse_settings("[review]\nreviewers = \"/code-review\"\n");
assert_eq!(scalar.reviewers, vec!["code-review".to_string()]);
let absent = parse_settings("[review]\ngithub_apps = []\n");
assert!(absent.reviewers.is_empty());
}
#[test]
fn parse_settings_reviewers_distinct_from_external_reviewers() {
let cfg = "external_reviewers = [\"gemini\"]\n\n[review]\nreviewers = [\"sigma\"]\n";
let s = parse_settings(cfg);
assert_eq!(s.external_reviewers, vec!["gemini".to_string()]);
assert_eq!(s.reviewers, vec!["sigma".to_string()]);
}
fn write_events(dir: &Path, lines: &[&str]) -> std::path::PathBuf {
let p = dir.join("events.jsonl");
std::fs::write(&p, lines.join("\n")).unwrap();
p
}
#[test]
fn reviewers_all_attested_empty_is_vacuously_true() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("nonexistent.jsonl");
assert!(reviewers_all_attested(&p, &[], "abc"));
}
#[test]
fn reviewers_all_attested_head_pinned_pass() {
let tmp = tempfile::tempdir().unwrap();
let p = write_events(
tmp.path(),
&[
r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"abc123","verdict":"pass"}}"#,
],
);
assert!(reviewers_all_attested(&p, &["sigma".to_string()], "abc123"));
}
#[test]
fn reviewers_all_attested_stale_head_is_unsatisfied() {
let tmp = tempfile::tempdir().unwrap();
let p = write_events(
tmp.path(),
&[
r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"OLD","verdict":"pass"}}"#,
],
);
assert!(!reviewers_all_attested(&p, &["sigma".to_string()], "NEW"));
}
#[test]
fn reviewers_all_attested_fail_and_missing_are_unsatisfied() {
let tmp = tempfile::tempdir().unwrap();
let fail = write_events(
tmp.path(),
&[
r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
],
);
assert!(!reviewers_all_attested(&fail, &["sigma".to_string()], "h"));
let gone = tmp.path().join("gone.jsonl");
assert!(!reviewers_all_attested(&gone, &["sigma".to_string()], "h"));
}
#[test]
fn reviewers_all_attested_conjunction_and_slash_normalized() {
let tmp = tempfile::tempdir().unwrap();
let p = write_events(
tmp.path(),
&[
r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
r#"{"ts":"t","type":"review_attestation","source":"target","data":{"reviewer":"code-review","head_sha":"h","verdict":"pass"}}"#,
],
);
assert!(reviewers_all_attested(
&p,
&["sigma".to_string(), "/code-review".to_string()],
"h"
));
assert!(!reviewers_all_attested(
&p,
&["sigma".to_string(), "declare".to_string()],
"h"
));
}
#[test]
fn parse_settings_reviewers_malformed_mapping_fails_closed() {
let s = parse_settings("[review]\nreviewers = {a = \"b\"}\n");
assert_eq!(s.reviewers, vec![MALFORMED_REVIEWERS_SENTINEL.to_string()]);
let tmp = tempfile::tempdir().unwrap();
let p = write_events(tmp.path(), &[]);
assert!(
!reviewers_all_attested(&p, &s.reviewers, "h"),
"a malformed-reviewers sentinel must never be satisfiable"
);
}
#[test]
fn parse_settings_reviewers_seq_with_nonscalar_fails_closed() {
let bad = parse_settings("[review]\nreviewers = [\"sigma\", {a = \"b\"}]\n");
assert_eq!(
bad.reviewers,
vec![MALFORMED_REVIEWERS_SENTINEL.to_string()]
);
let ok = parse_settings("[review]\nreviewers = [\"sigma\", \"declare\"]\n");
assert_eq!(
ok.reviewers,
vec!["sigma".to_string(), "declare".to_string()]
);
}
#[test]
fn reviewers_all_attested_latest_verdict_wins() {
let tmp = tempfile::tempdir().unwrap();
let pf = write_events(
tmp.path(),
&[
r#"{"ts":"t1","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
r#"{"ts":"t2","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
],
);
assert!(
!reviewers_all_attested(&pf, &["sigma".to_string()], "h"),
"a fail posted after a pass must revoke it"
);
let fp = write_events(
tmp.path(),
&[
r#"{"ts":"t1","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"fail"}}"#,
r#"{"ts":"t2","type":"review_attestation","source":"target","data":{"reviewer":"sigma","head_sha":"h","verdict":"pass"}}"#,
],
);
assert!(
reviewers_all_attested(&fp, &["sigma".to_string()], "h"),
"a pass posted after a fail must restore satisfaction"
);
}
#[test]
fn review_finding_open_then_resolved_clears() {
let tmp = tempfile::tempdir().unwrap();
let open = write_events(
tmp.path(),
&[
r#"{"ts":"t1","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-1","text":"off-by-one in the loop\nsecond line"}}"#,
],
);
let (findings, malformed) = open_review_findings(&open, "x-1");
assert_eq!(malformed, 0);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].id, "f1");
assert_eq!(findings[0].first_line, "off-by-one in the loop");
let resolved = write_events(
tmp.path(),
&[
r#"{"ts":"t1","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-1","text":"off-by-one"}}"#,
r#"{"ts":"t2","type":"review_finding_resolved","source":"observer","data":{"finding_id":"f1"}}"#,
],
);
assert!(open_review_findings(&resolved, "x-1").0.is_empty());
}
#[test]
fn review_finding_is_node_scoped() {
let tmp = tempfile::tempdir().unwrap();
let p = write_events(
tmp.path(),
&[
r#"{"ts":"t","type":"review_finding","source":"observer","data":{"finding_id":"f1","node":"x-OTHER","text":"not mine"}}"#,
],
);
assert!(open_review_findings(&p, "x-mine").0.is_empty());
assert_eq!(open_review_findings(&p, "x-OTHER").0.len(), 1);
}
#[test]
fn review_finding_malformed_notices_not_blocks() {
let tmp = tempfile::tempdir().unwrap();
let truncated = r#"{"ts":"t","type":"review_finding","data":{"finding_id":"f1"#;
let id_less = r#"{"ts":"t","type":"review_finding","source":"observer","data":{"node":"x-1","text":"no id"}}"#;
let good = r#"{"ts":"t","type":"review_finding","source":"observer","data":{"finding_id":"good","node":"x-1","text":"real one"}}"#;
let p = write_events(tmp.path(), &[truncated, id_less, good]);
let (findings, malformed) = open_review_findings(&p, "x-1");
assert_eq!(findings.len(), 1, "only the well-formed finding gates");
assert_eq!(findings[0].id, "good");
assert_eq!(
malformed, 2,
"the truncated line + the id-less line are noticed"
);
}
#[test]
fn review_finding_block_reason_quotes_first_plus_count() {
let open = vec![
OpenFinding {
id: "aaa".into(),
first_line: "the bug".into(),
},
OpenFinding {
id: "bbb".into(),
first_line: "another".into(),
},
];
let r = build_findings_block_reason(&open, 1);
assert!(r.contains("aaa"));
assert!(r.contains("the bug"));
assert!(r.contains("fno annotate resolve aaa"));
assert!(r.contains("[+1 more]"));
assert!(r.contains("1 malformed"));
}
#[test]
fn resolved_optional_is_separate_from_required() {
let s = parse_settings(
"[review]\ngithub_apps = []\noptional_apps = [\"chatgpt-codex-connector\"]\n",
);
assert!(
resolved_required_bots(&s).is_empty(),
"optional must not be required"
);
assert_eq!(
resolved_optional_bots(&s),
vec!["chatgpt-codex-connector".to_string()]
);
}
#[test]
fn parse_settings_github_apps_block_list() {
let cfg = "[review]\ngithub_apps = [\n \"chatgpt-codex-connector\",\n]\n";
let s = parse_settings(cfg);
assert_eq!(
s.github_apps,
Some(vec!["chatgpt-codex-connector".to_string()])
);
}
#[test]
fn parse_settings_github_apps_inline_and_empty() {
let s = parse_settings("[review]\ngithub_apps = [\"a\", \"b\"]\n");
assert_eq!(s.github_apps, Some(vec!["a".to_string(), "b".to_string()]));
let e = parse_settings("[review]\ngithub_apps = []\n");
assert_eq!(e.github_apps, Some(Vec::new()));
}
#[test]
fn resolved_github_apps_wins_over_required_bots_alias() {
let s = Settings {
github_apps: Some(vec!["new-bot".to_string()]),
required_bots: Some(vec!["old-bot".to_string()]),
..Default::default()
};
assert_eq!(resolved_required_bots(&s), vec!["new-bot".to_string()]);
let legacy = Settings {
required_bots: Some(vec!["old-bot".to_string()]),
..Default::default()
};
assert_eq!(resolved_required_bots(&legacy), vec!["old-bot".to_string()]);
}
#[test]
fn parse_settings_peers_inline_scalars() {
let cfg = "[review]\npeers = [\"codex\", \"gemini\"]\npeer_identity = \"fno-peer-bot\"\n";
let s = parse_settings(cfg);
assert_eq!(s.peers.len(), 2);
assert_eq!(s.peers[0].provider, "codex");
assert_eq!(s.peer_identity.as_deref(), Some("fno-peer-bot"));
}
#[test]
fn parse_settings_peers_block_maps_with_identity() {
let cfg = "[review]\npeers = [{provider = \"codex\", identity = \"fno-codex-bot\"}, \"gemini\"]\n";
let s = parse_settings(cfg);
assert_eq!(s.peers.len(), 2);
assert_eq!(s.peers[0].provider, "codex");
assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
assert_eq!(s.peers[1].provider, "gemini");
assert_eq!(s.peers[1].identity, None);
}
#[test]
fn resolved_peers_shared_identity_collapses_to_one_login() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![
PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
},
PeerEntry {
provider: "gemini".into(),
model: None,
identity: None,
},
],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
assert_eq!(resolved_required_bots(&s), vec!["fno-peer-bot".to_string()]);
}
#[test]
fn resolved_peers_per_entry_identities_each_add_a_login() {
let s = Settings {
github_apps: Some(vec!["chatgpt-codex-connector".into()]),
peers: vec![
PeerEntry {
provider: "codex".into(),
model: None,
identity: Some("fno-codex-bot".into()),
},
PeerEntry {
provider: "gemini".into(),
model: None,
identity: Some("fno-gemini-bot".into()),
},
],
..Default::default()
};
assert_eq!(
resolved_required_bots(&s),
vec![
"chatgpt-codex-connector".to_string(),
"fno-codex-bot".to_string(),
"fno-gemini-bot".to_string(),
]
);
}
#[test]
fn parse_settings_github_apps_and_peers_together() {
let cfg = "[review]\ngithub_apps = [\"chatgpt-codex-connector\"]\npeers = [\"codex\"]\npeer_identity = \"fno-peer-bot\"\n";
let s = parse_settings(cfg);
assert_eq!(
s.github_apps,
Some(vec!["chatgpt-codex-connector".to_string()]),
"github_apps item must be collected"
);
assert_eq!(s.peers.len(), 1, "peers item must be collected");
assert_eq!(s.peers[0].provider, "codex");
assert_eq!(s.peer_identity.as_deref(), Some("fno-peer-bot"));
}
#[test]
fn parse_settings_required_bots_single_item() {
let cfg = "[review]\nrequired_bots = [\"chatgpt-codex-connector\"]\n";
let s = parse_settings(cfg);
assert_eq!(
s.required_bots,
Some(vec!["chatgpt-codex-connector".to_string()])
);
}
#[test]
fn parse_settings_peers_single_mapping_is_one_peer() {
let block = parse_settings(
"[review]\npeers = {provider = \"codex\", identity = \"fno-codex-bot\"}\n",
);
assert_eq!(block.peers.len(), 1, "table peers must be one peer");
assert_eq!(block.peers[0].provider, "codex");
assert_eq!(block.peers[0].identity.as_deref(), Some("fno-codex-bot"));
let dotted = parse_settings(
"[review.peers]\nprovider = \"gemini\"\nidentity = \"fno-gemini-bot\"\n",
);
assert_eq!(dotted.peers.len(), 1);
assert_eq!(dotted.peers[0].provider, "gemini");
assert_eq!(dotted.peers[0].identity.as_deref(), Some("fno-gemini-bot"));
}
#[test]
fn parse_settings_peers_bare_scalar_is_one_provider() {
let cfg = "[review]\npeers = \"codex\"\npeer_identity = \"fno-peer-bot\"\n";
let s = parse_settings(cfg);
assert_eq!(s.peers.len(), 1);
assert_eq!(s.peers[0].provider, "codex");
assert_eq!(resolved_required_bots(&s), vec!["fno-peer-bot".to_string()]);
}
#[test]
fn parse_settings_peers_array_of_tables() {
let cfg = "[review]\npeers = [{provider = \"codex\", identity = \"fno-codex-bot\"}, \"gemini\"]\n";
let s = parse_settings(cfg);
assert_eq!(s.peers.len(), 2);
assert_eq!(s.peers[0].provider, "codex");
assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
assert_eq!(s.peers[1].provider, "gemini");
}
#[test]
fn parse_settings_peers_map_identity_before_provider() {
let cfg = "[review]\npeers = [{identity = \"fno-codex-bot\", provider = \"codex\"}, {provider = \"gemini\", identity = \"fno-gemini-bot\"}]\n";
let s = parse_settings(cfg);
assert_eq!(s.peers.len(), 2);
assert_eq!(s.peers[0].provider, "codex");
assert_eq!(s.peers[0].identity.as_deref(), Some("fno-codex-bot"));
assert_eq!(s.peers[1].provider, "gemini");
assert_eq!(s.peers[1].identity.as_deref(), Some("fno-gemini-bot"));
}
#[test]
fn identity_free_peer_uses_local_attestation_not_a_login() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![PeerEntry {
provider: "gemini".into(),
model: None,
identity: None,
}],
peer_identity: None,
..Default::default()
};
assert!(resolved_required_bots_for_author(&s, Some("codex")).is_empty());
assert_eq!(
resolved_local_peer_reviewers_for_author(&s, Some("codex")),
vec![LOCAL_PEER_REVIEWER.to_string()]
);
}
#[test]
fn identity_free_same_model_peer_is_an_unsatisfiable_local_gate() {
let s = Settings {
peers: vec![PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
}],
..Default::default()
};
assert_eq!(
resolved_local_peer_reviewers_for_author(&s, Some("codex")),
vec![SAME_MODEL_LOCAL_PEER_SENTINEL.to_string()]
);
}
#[test]
fn identity_free_mixed_peers_form_one_composite_gate() {
let s = Settings {
peers: vec![
PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
},
PeerEntry {
provider: "claude".into(),
model: Some("zai,glm-5.2".into()),
identity: None,
},
],
..Default::default()
};
assert_eq!(
resolved_local_peer_reviewers_for_author(&s, Some("codex")),
vec![LOCAL_PEER_REVIEWER.to_string()]
);
}
#[test]
fn explicit_peer_identity_keeps_login_gate_only() {
let s = Settings {
peers: vec![PeerEntry {
provider: "gemini".into(),
model: None,
identity: Some("fno-gemini-bot".into()),
}],
..Default::default()
};
assert_eq!(
resolved_required_bots_for_author(&s, Some("codex")),
vec!["fno-gemini-bot".to_string()]
);
assert!(resolved_local_peer_reviewers_for_author(&s, Some("codex")).is_empty());
}
#[test]
fn local_peer_attestation_is_head_pinned() {
let td = tempfile::tempdir().unwrap();
let events = td.path().join("events.jsonl");
std::fs::write(
&events,
r#"{"type":"review_attestation","data":{"reviewer":"peer","head_sha":"OLD","verdict":"pass"}}"#,
)
.unwrap();
let peer = vec![LOCAL_PEER_REVIEWER.to_string()];
assert!(!reviewers_all_attested(&events, &peer, "NEW"));
std::fs::write(
&events,
r#"{"type":"review_attestation","data":{"reviewer":"peer","head_sha":"NEW","verdict":"pass"}}"#,
)
.unwrap();
assert!(reviewers_all_attested(&events, &peer, "NEW"));
}
#[test]
fn peer_family_mapping_table() {
let bare = |p: &str| PeerEntry {
provider: p.into(),
model: None,
identity: None,
};
let routed = |p: &str, m: &str| PeerEntry {
provider: p.into(),
model: Some(m.into()),
identity: None,
};
assert_eq!(harness_family("claude"), Some("anthropic"));
assert_eq!(harness_family("ANTHROPIC"), Some("anthropic"));
assert_eq!(harness_family("codex"), Some("openai"));
assert_eq!(harness_family("gemini"), Some("google"));
assert_eq!(harness_family("zai"), None);
assert_eq!(route_provider("zai,glm-5.2"), Some("zai"));
assert_eq!(route_provider(" openai , gpt-5 "), Some("openai"));
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"));
assert_eq!(peer_family(&bare("grok")), None); assert_eq!(peer_family(&routed("claude", "zai,glm-5.2")), None); assert_eq!(
peer_family(&routed("codex", "openai,gpt-5")),
Some("openai")
);
assert_eq!(peer_family(&routed("codex", "gpt-5")), Some("openai")); }
#[test]
fn same_model_peer_holds_gate() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
}],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("codex"));
assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
}
#[test]
fn cross_model_peer_login_unchanged() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![PeerEntry {
provider: "gemini".into(),
model: None,
identity: None,
}],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("codex"));
assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
}
#[test]
fn routed_claude_peer_is_cross_model() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![PeerEntry {
provider: "claude".into(),
model: Some("zai,glm-5.2".into()),
identity: None,
}],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("claude"));
assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
}
#[test]
fn same_family_route_holds_gate() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![PeerEntry {
provider: "claude".into(),
model: Some("anthropic,claude-opus".into()),
identity: None,
}],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("claude"));
assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
}
#[test]
fn shared_identity_mixed_peers_stays_satisfiable() {
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![
PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
},
PeerEntry {
provider: "gemini".into(),
model: None,
identity: None,
},
],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("codex"));
assert_eq!(logins, vec!["fno-peer-bot".to_string()]);
}
#[test]
fn unknown_harness_is_byte_identical() {
let s = Settings {
github_apps: Some(vec!["chatgpt-codex-connector".into()]),
peers: vec![PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
}],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
assert_eq!(
resolved_required_bots_for_author(&s, None),
resolved_required_bots(&s)
);
assert!(!resolved_required_bots_for_author(&s, None)
.iter()
.any(|l| l == SAME_MODEL_PEER_SENTINEL));
}
#[test]
fn base_app_login_collision_is_fail_closed() {
let s = Settings {
github_apps: Some(vec!["fno-peer-bot".into()]),
peers: vec![PeerEntry {
provider: "codex".into(),
model: None,
identity: None,
}],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("codex"));
assert!(logins.iter().any(|l| l == "fno-peer-bot")); assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL)); }
#[test]
fn non_claude_route_is_ignored() {
let routed_codex = PeerEntry {
provider: "codex".into(),
model: Some("zai,glm-5.2".into()),
identity: None,
};
assert_eq!(peer_family(&routed_codex), Some("openai"));
let s = Settings {
github_apps: Some(Vec::new()),
peers: vec![routed_codex],
peer_identity: Some("fno-peer-bot".into()),
..Default::default()
};
let logins = resolved_required_bots_for_author(&s, Some("codex"));
assert!(logins.iter().any(|l| l == SAME_MODEL_PEER_SENTINEL));
assert!(!logins.iter().any(|l| l == "fno-peer-bot"));
}
#[test]
fn login_matches_bot_cases() {
assert!(login_matches_bot(
"chatgpt-codex-connector",
"chatgpt-codex-connector"
));
assert!(login_matches_bot(
"chatgpt-codex-connector[bot]",
"chatgpt-codex-connector"
));
assert!(login_matches_bot("chatgpt-codex-connector", "codex"));
assert!(login_matches_bot("Gemini-Code-Assist[bot]", "gemini"));
assert!(!login_matches_bot("alice-the-human", "codex"));
assert!(!login_matches_bot("anyone", ""));
}
#[test]
fn compute_review_info_per_bot_verdict() {
let required = vec![
"chatgpt-codex-connector".to_string(),
"gemini-code-assist".to_string(),
];
let json = serde_json::json!({
"reviews": [
{"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
"submittedAt": "2026-06-05T01:00:00Z"}
],
"comments": []
});
let info = compute_review_info(&json, &required);
assert!(!info.all_required_passed());
assert_eq!(info.missing_bots, vec!["gemini-code-assist".to_string()]);
assert_eq!(info.latest_ts, "2026-06-05T01:00:00Z");
}
fn nudge_cfg() -> NudgeConfig {
NudgeConfig {
login: "chatgpt-codex-connector".into(),
review_handle: "@codex review".into(),
wait_minutes: 15,
ceiling: 3,
}
}
fn nudge_now() -> DateTime<Utc> {
"2026-07-06T02:00:00Z".parse().unwrap()
}
fn mention(body: &str, created: &str) -> Value {
serde_json::json!({"body": body, "createdAt": created})
}
#[test]
fn nudge_needs_nudge_when_never_mentioned() {
let cfg = nudge_cfg();
let b = classify_bot_nudge("chatgpt-codex-connector", &[], Some(&cfg), nudge_now());
assert_eq!(b.class, NudgeClass::NeedsNudge);
assert_eq!(b.nudges, 0);
assert_eq!(b.review_handle, "@codex review");
}
#[test]
fn nudge_awaiting_within_window() {
let cfg = nudge_cfg();
let comments = vec![mention("@codex review", "2026-07-06T01:58:00Z")];
let b = classify_bot_nudge(
"chatgpt-codex-connector",
&comments,
Some(&cfg),
nudge_now(),
);
assert_eq!(b.class, NudgeClass::Awaiting);
assert_eq!(b.nudges, 1);
assert!(b.newest_age_min <= 2);
}
#[test]
fn nudge_unresponsive_after_ceiling() {
let cfg = nudge_cfg();
let comments = vec![
mention("@codex review", "2026-07-06T00:00:00Z"),
mention("hey @codex review please", "2026-07-06T00:30:00Z"),
mention("@codex review", "2026-07-06T01:00:00Z"),
];
let b = classify_bot_nudge(
"chatgpt-codex-connector",
&comments,
Some(&cfg),
nudge_now(),
);
assert_eq!(b.class, NudgeClass::Unresponsive);
assert_eq!(b.nudges, 3);
assert!(b.span_min >= 120, "span was {}", b.span_min);
}
#[test]
fn nudge_reask_after_timeout_below_ceiling() {
let cfg = nudge_cfg();
let comments = vec![mention("@codex review", "2026-07-06T01:00:00Z")];
let b = classify_bot_nudge(
"chatgpt-codex-connector",
&comments,
Some(&cfg),
nudge_now(),
);
assert_eq!(b.class, NudgeClass::NeedsNudge);
assert_eq!(b.nudges, 1);
}
#[test]
fn nudge_none_cfg_is_not_nudgeable() {
let b2 = classify_bot_nudge(SAME_MODEL_PEER_SENTINEL, &[], None, nudge_now());
assert_eq!(b2.class, NudgeClass::NotNudgeable);
}
#[test]
fn nudge_malformed_created_at_is_needs_nudge() {
let cfg = nudge_cfg();
let comments = vec![mention("@codex review", "not-a-date")];
let b = classify_bot_nudge(
"chatgpt-codex-connector",
&comments,
Some(&cfg),
nudge_now(),
);
assert_eq!(b.class, NudgeClass::NeedsNudge);
assert_eq!(b.nudges, 1);
}
#[test]
fn resolved_nudge_configs_default_nudges_codex_only() {
let cfgs = resolved_nudge_configs(&Settings::default());
let codex = cfgs
.iter()
.find(|c| c.login == "chatgpt-codex-connector")
.expect("codex nudgeable by default");
assert_eq!(codex.review_handle, "@codex review");
assert_eq!(codex.wait_minutes, 15);
assert_eq!(codex.ceiling, 3);
assert!(cfgs.iter().all(|c| c.login != "gemini-code-assist"));
}
#[test]
fn nudge_override_sets_wait_and_ceiling_inheriting_handle() {
let s = parse_settings(
"[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = 30, ceiling = 5 }\n",
);
let cfgs = resolved_nudge_configs(&s);
let codex = cfgs
.iter()
.find(|c| logins_correspond(&c.login, "chatgpt-codex-connector"))
.unwrap();
assert_eq!(codex.wait_minutes, 30);
assert_eq!(codex.ceiling, 5);
assert_eq!(codex.review_handle, "@codex review");
}
#[test]
fn nudge_override_disabled_removes_login() {
let s =
parse_settings("[review.nudge]\n\"chatgpt-codex-connector\" = { enabled = false }\n");
let cfgs = resolved_nudge_configs(&s);
assert!(cfgs
.iter()
.all(|c| !logins_correspond(&c.login, "chatgpt-codex-connector")));
}
#[test]
fn nudge_override_new_login() {
let s = parse_settings(
"[review.nudge]\n\"some-bot\" = { review_handle = \"@somebot review\", wait_minutes = 10, ceiling = 2 }\n",
);
let cfgs = resolved_nudge_configs(&s);
let b = cfgs.iter().find(|c| c.login == "some-bot").unwrap();
assert_eq!(b.review_handle, "@somebot review");
assert_eq!(b.wait_minutes, 10);
assert_eq!(b.ceiling, 2);
}
#[test]
fn nudge_malformed_override_degrades_to_non_nudgeable() {
for body in [
"[review.nudge]\n\"chatgpt-codex-connector\" = \"scalar\"\n",
"[review.nudge]\n\"chatgpt-codex-connector\" = [1, 2]\n",
"[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = \"soon\" }\n",
"[review.nudge]\n\"chatgpt-codex-connector\" = { wait_minutes = 9999999999999999 }\n",
] {
let s = parse_settings(body);
let cfgs = resolved_nudge_configs(&s);
assert!(
cfgs.iter()
.all(|c| !logins_correspond(&c.login, "chatgpt-codex-connector")),
"malformed override must be non-nudgeable: {body}"
);
}
}
#[test]
fn compute_review_info_empty_state_not_a_pass() {
let required = vec!["chatgpt-codex-connector".to_string()];
let json = serde_json::json!({
"reviews": [
{"author": {"login": "chatgpt-codex-connector"}, "state": "",
"submittedAt": "2026-06-05T01:00:00Z"}
],
"comments": []
});
let info = compute_review_info(&json, &required);
assert!(!info.all_required_passed());
}
#[test]
fn compute_review_info_usage_limited_bot_dropped() {
let required = vec!["chatgpt-codex-connector".to_string()];
let json = serde_json::json!({
"reviews": [],
"comments": [
{"author": {"login": "chatgpt-codex-connector"},
"body": "You have reached your Codex usage limits for code reviews.",
"createdAt": "2026-07-06T01:00:00Z"}
]
});
let info = compute_review_info(&json, &required);
assert!(info.missing_bots.is_empty());
assert_eq!(
info.usage_limited,
vec!["chatgpt-codex-connector".to_string()]
);
assert!(info.all_required_passed());
}
#[test]
fn compute_review_info_usage_limit_only_own_comment_counts() {
let required = vec!["chatgpt-codex-connector".to_string()];
let json = serde_json::json!({
"reviews": [],
"comments": [
{"author": {"login": "some-human"},
"body": "The bot hit its usage limits for code reviews, ugh.",
"createdAt": "2026-07-06T01:00:00Z"}
]
});
let info = compute_review_info(&json, &required);
assert_eq!(
info.missing_bots,
vec!["chatgpt-codex-connector".to_string()]
);
assert!(info.usage_limited.is_empty());
assert!(!info.all_required_passed());
}
#[test]
fn compute_review_info_real_review_beats_ratelimit_comment() {
let required = vec!["chatgpt-codex-connector".to_string()];
let json = serde_json::json!({
"reviews": [
{"author": {"login": "chatgpt-codex-connector"}, "state": "COMMENTED",
"submittedAt": "2026-07-06T02:00:00Z"}
],
"comments": [
{"author": {"login": "chatgpt-codex-connector"},
"body": "codex usage limits reached",
"createdAt": "2026-07-06T01:00:00Z"}
]
});
let info = compute_review_info(&json, &required);
assert!(info.missing_bots.is_empty());
assert!(info.usage_limited.is_empty());
assert!(info.all_required_passed());
}
#[test]
fn blocking_severity_codex_p1_both_forms() {
assert_eq!(
blocking_severity(" Bug"),
Some("P1")
);
assert_eq!(blocking_severity("![P1 Badge] something"), Some("P1"));
assert_eq!(
blocking_severity("see https://img.shields.io/badge/P1-orange"),
Some("P1")
);
}
#[test]
fn blocking_severity_codex_p2_p3_advisory() {
assert_eq!(
blocking_severity(" nit"),
None
);
assert_eq!(
blocking_severity(" nit"),
None
);
}
#[test]
fn blocking_severity_gemini_critical_high_blocking() {
assert_eq!(
blocking_severity(
" bad"
),
Some("critical")
);
assert_eq!(
blocking_severity(
" bad"
),
Some("high")
);
}
#[test]
fn blocking_severity_gemini_medium_low_advisory() {
assert_eq!(
blocking_severity(
" hmm"
),
None
);
assert_eq!(
blocking_severity(
" hmm"
),
None
);
}
#[test]
fn blocking_severity_unparseable_is_advisory() {
assert_eq!(blocking_severity("just a comment with no badge"), None);
assert_eq!(blocking_severity(""), None);
assert_eq!(blocking_severity("P1 mentioned in prose only"), None);
}
#[test]
fn max_ts_none_handling() {
assert_eq!(
max_ts("none", "2026-06-05T01:00:00Z"),
"2026-06-05T01:00:00Z"
);
assert_eq!(
max_ts("2026-06-05T01:00:00Z", "none"),
"2026-06-05T01:00:00Z"
);
assert_eq!(max_ts("none", "none"), "none");
assert_eq!(max_ts("", ""), "none");
assert_eq!(
max_ts("2026-06-05T01:00:00Z", "2026-06-05T02:00:00Z"),
"2026-06-05T02:00:00Z"
);
}
fn finding_comment(id: i64, body: &str, created_at: &str) -> Value {
serde_json::json!({
"id": id,
"in_reply_to_id": null,
"user": {"login": "chatgpt-codex-connector[bot]"},
"body": body,
"path": "src/x.rs",
"line": 42,
"created_at": created_at
})
}
fn reply_comment(id: i64, parent: i64, login: &str, body: &str, created_at: &str) -> Value {
serde_json::json!({
"id": id,
"in_reply_to_id": parent,
"user": {"login": login},
"body": body,
"created_at": created_at
})
}
const REQ: &[&str] = &["chatgpt-codex-connector"];
fn req_vec() -> Vec<String> {
REQ.iter().map(|s| s.to_string()).collect()
}
#[test]
fn finding_no_reply_is_unaddressed() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
)];
let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
assert_eq!(ts, "2026-06-05T01:10:00Z");
assert_eq!(unaddressed.len(), 1);
assert_eq!(unaddressed[0].path, "src/x.rs");
assert_eq!(unaddressed[0].line, 42);
assert_eq!(unaddressed[0].severity, "P1");
}
#[test]
fn finding_reply_plus_commit_after_is_addressed() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
),
reply_comment(
101,
100,
"bllshttng",
"fixed in abc123",
"2026-06-05T01:20:00Z",
),
];
let commits = vec!["2026-06-05T01:30:00Z".to_string()];
let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
assert!(unaddressed.is_empty(), "commit-after arm must address");
}
#[test]
fn finding_wontfix_reply_is_addressed_without_commit() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
),
reply_comment(
101,
100,
"bllshttng",
"wontfix: intentional - documented tradeoff",
"2026-06-05T01:20:00Z",
),
];
let commits = vec!["2026-06-05T01:00:00Z".to_string()];
let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
assert!(unaddressed.is_empty(), "wontfix arm must address alone");
}
#[test]
fn finding_commit_without_reply_is_unaddressed() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
)];
let commits = vec!["2026-06-05T01:30:00Z".to_string()];
let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
assert_eq!(unaddressed.len(), 1, "commit alone must not address");
}
#[test]
fn finding_bot_reply_only_is_unaddressed() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
),
reply_comment(
101,
100,
"chatgpt-codex-connector[bot]",
"elaborating on my finding",
"2026-06-05T01:15:00Z",
),
];
let commits = vec!["2026-06-05T01:30:00Z".to_string()];
let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
assert_eq!(unaddressed.len(), 1, "bot self-reply must not count as ack");
}
#[test]
fn finding_reply_without_commit_or_wontfix_is_unaddressed() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
),
reply_comment(
101,
100,
"bllshttng",
"looking into it",
"2026-06-05T01:20:00Z",
),
];
let commits = vec!["2026-06-05T01:00:00Z".to_string()]; let (_, unaddressed) = compute_unaddressed_findings(&comments, &commits, &req_vec(), &[]);
assert_eq!(unaddressed.len(), 1);
}
#[test]
fn finding_from_non_required_bot_ignored() {
let comments = vec![serde_json::json!({
"id": 200,
"in_reply_to_id": null,
"user": {"login": "gemini-code-assist[bot]"},
"body": " eh",
"path": "src/y.rs",
"line": 7,
"created_at": "2026-06-05T01:10:00Z"
})];
let (ts, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
assert!(unaddressed.is_empty());
assert_eq!(ts, "2026-06-05T01:10:00Z");
}
#[test]
fn empty_comments_no_findings() {
let (ts, unaddressed) = compute_unaddressed_findings(&[], &[], &req_vec(), &[]);
assert_eq!(ts, "none");
assert!(unaddressed.is_empty());
}
#[test]
fn finding_missing_id_skipped_not_pooled() {
let no_id = serde_json::json!({
"in_reply_to_id": null,
"user": {"login": "chatgpt-codex-connector[bot]"},
"body": " idless",
"path": "src/z.rs", "line": 3,
"created_at": "2026-06-05T01:05:00Z"
});
let real = finding_comment(
100,
" real",
"2026-06-05T01:10:00Z",
);
let stray = reply_comment(
101,
0,
"bllshttng",
"wontfix: stray",
"2026-06-05T01:20:00Z",
);
let comments = vec![no_id, real, stray];
let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
assert_eq!(unaddressed.len(), 1, "only the real finding remains");
assert_eq!(unaddressed[0].id, 100);
}
#[test]
fn ts_after_parses_offsets_correctly() {
assert!(!ts_after(
"2026-06-05T23:30:00+13:00",
"2026-06-05T11:00:00Z"
));
assert!(ts_after(
"2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"
));
assert!(ts_after("2026-06-05T11:00:01Z", "2026-06-05T11:00:00Z"));
assert!(!ts_after("2026-06-05T11:00:00Z", "2026-06-05T11:00:00Z"));
assert!(!ts_after("garbage", "2026-06-05T11:00:00Z"));
assert!(!ts_after("2026-06-05T11:00:00Z", "garbage"));
assert!(!ts_after("2026-06-05T11:00:00Z", ""));
}
#[test]
fn max_ts_chronological_with_offsets() {
assert_eq!(
max_ts("2026-06-05T23:30:00+13:00", "2026-06-05T11:00:00Z"),
"2026-06-05T11:00:00Z"
);
assert_eq!(
max_ts("2026-06-05T23:30:00+10:00", "2026-06-05T11:00:00Z"),
"2026-06-05T23:30:00+10:00"
);
}
#[test]
fn finding_reply_listed_before_finding_still_addressed() {
let comments = vec bug",
"2026-06-05T01:10:00Z",
),
];
let (_, unaddressed) = compute_unaddressed_findings(&comments, &[], &req_vec(), &[]);
assert!(
unaddressed.is_empty(),
"reply-before-finding ordering must still ack"
);
}
#[test]
fn no_pr_stderr_detected() {
assert!(is_no_pr_stderr(
b"no pull requests found for branch \"feat\""
));
assert!(is_no_pr_stderr(b"No pull requests found for branch \"x\""));
assert!(!is_no_pr_stderr(b"connect: network is unreachable"));
assert!(!is_no_pr_stderr(b"API rate limit exceeded"));
assert!(!is_no_pr_stderr(b""));
}
}
#[cfg(test)]
mod done_probe_tests {
use super::*;
use std::time::Duration;
fn fm(body: &str) -> String {
format!("---\ntitle: t\n{body}\n---\n\n# doc\n")
}
fn probes_of(doc: &str) -> Vec<String> {
match parse_done_probes(doc) {
ProbeDecl::Probes(p) => p,
other => panic!("expected probes, got {other:?}"),
}
}
#[test]
fn parses_block_list() {
let doc = fm("done_probes:\n - \"fno mail list --since 24h | grep -q groom\"\n - 'echo ok'\nstatus: ready");
assert_eq!(
probes_of(&doc),
vec![
"fno mail list --since 24h | grep -q groom".to_string(),
"echo ok".to_string()
]
);
}
#[test]
fn parses_inline_list_keeping_commas_inside_commands() {
let doc = fm(r#"done_probes: ["gh api x --jq '.a,.b'", "echo ok"]"#);
assert_eq!(
probes_of(&doc),
vec!["gh api x --jq '.a,.b'".to_string(), "echo ok".to_string()],
"a comma inside a quoted command must not split it into two probes"
);
}
#[test]
fn absent_field_and_explicit_empty_list_are_both_no_gate() {
assert_eq!(parse_done_probes(&fm("done_probes: []")), ProbeDecl::None);
assert_eq!(parse_done_probes(&fm("status: ready")), ProbeDecl::None);
assert_eq!(parse_done_probes("no frontmatter here"), ProbeDecl::None);
}
#[test]
fn a_declaration_this_parser_cannot_read_is_never_no_gate() {
let multiline_inline = fm("done_probes: [\n \"echo a\",\n \"echo b\"\n]");
assert_eq!(parse_done_probes(&multiline_inline), ProbeDecl::Unparseable);
assert_eq!(
parse_done_probes(&fm("done_probes:\nstatus: ready")),
ProbeDecl::Unparseable,
"a declared-but-empty block must refuse, not pass"
);
}
#[test]
fn inline_list_keeps_escaped_quotes_inside_a_command() {
let doc = fm(r#"done_probes: ["sh -c \"echo hi\"", "echo ok"]"#);
assert_eq!(
probes_of(&doc),
vec![r#"sh -c "echo hi""#.to_string(), "echo ok".to_string()]
);
}
#[test]
fn inline_list_preserves_a_trailing_bracket_and_refuses_an_unterminated_one() {
assert_eq!(
probes_of(&fm(r#"done_probes: ["echo [hi]"]"#)),
vec!["echo [hi]".to_string()],
"only the list's own closing bracket may be stripped"
);
assert_eq!(
parse_done_probes(&fm(r#"done_probes: ["echo a""#)),
ProbeDecl::Unparseable,
"an unterminated inline list must refuse, not silently parse"
);
}
#[test]
fn a_comment_inside_the_block_does_not_swallow_the_probes() {
let doc = fm("done_probes:\n # why this probe exists\n - echo a\n - echo b\ntags: []");
assert_eq!(
probes_of(&doc),
vec!["echo a".to_string(), "echo b".to_string()]
);
}
#[test]
fn block_list_stops_at_the_next_key() {
let doc = fm("done_probes:\n - echo a\ntags: []\nother: x");
assert_eq!(probes_of(&doc), vec!["echo a".to_string()]);
}
#[test]
fn probe_outcomes_render_pass_fail_and_exit_code() {
let tmp = tempfile::tempdir().unwrap();
let t = Duration::from_secs(10);
assert_eq!(run_probe("exit 0", tmp.path(), t).render(), "pass");
assert_eq!(run_probe("exit 3", tmp.path(), t).render(), "fail:3");
assert_eq!(
run_probe("fno-no-such-binary-xyz", tmp.path(), t).render(),
"fail:127",
"a missing binary must fail closed as 127, never pass"
);
}
#[test]
fn hanging_probe_is_killed_within_the_timeout_budget() {
let tmp = tempfile::tempdir().unwrap();
let start = std::time::Instant::now();
let outcome = run_probe("sleep 30", tmp.path(), Duration::from_millis(200));
assert_eq!(outcome.render(), "timeout");
assert!(
start.elapsed() < Duration::from_secs(5),
"run_probe must return on its own timeout, not wait out the child"
);
}
#[test]
fn chatty_probe_does_not_deadlock_on_the_stderr_pipe() {
let tmp = tempfile::tempdir().unwrap();
let outcome = run_probe(
"head -c 200000 /dev/zero | tr '\\0' 'x' >&2; exit 1",
tmp.path(),
Duration::from_secs(20),
);
assert_eq!(outcome.render(), "fail:1");
match outcome {
ProbeOutcome::Fail { stderr, .. } => assert!(
stderr.len() <= PROBE_STDERR_CAP,
"stderr must be truncated to {PROBE_STDERR_CAP}"
),
_ => panic!("expected Fail"),
}
}
#[test]
fn over_cap_declaration_refuses_without_running_anything() {
let tmp = tempfile::tempdir().unwrap();
let plan = tmp.path().join("plan.md");
let sentinel = tmp.path().join("ran");
std::fs::write(
&plan,
fm(&format!(
"done_probes:\n - touch {0}\n - echo b\n - echo c\n - echo d",
sentinel.display()
)),
)
.unwrap();
let events = tmp.path().join("events.jsonl");
match evaluate_done_probes(
plan.to_str(),
None,
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Fail { reason, .. } => {
assert!(
reason.contains("cap is 3"),
"reason names the cap: {reason}"
)
}
_ => panic!("over-cap declaration must refuse"),
}
assert!(!sentinel.exists(), "an over-cap list must not execute");
}
#[test]
fn unreadable_plan_fails_closed_only_when_probes_were_seen_before() {
let tmp = tempfile::tempdir().unwrap();
let events = tmp.path().join("events.jsonl");
let missing = tmp.path().join("gone.md");
assert!(matches!(
evaluate_done_probes(
missing.to_str(),
None,
tmp.path(),
&events,
"s1",
Duration::from_secs(10)
),
ProbeGate::Absent
));
std::fs::write(
&events,
"{\"type\":\"loop_check\",\"data\":{\"session_id\":\"s1\",\"done_probes\":{\"echo ok\":\"pass\"}}}\n",
)
.unwrap();
match evaluate_done_probes(
missing.to_str(),
None,
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Fail { reason, .. } => assert!(
reason.contains("undeterminable"),
"reason must say undeterminable: {reason}"
),
_ => panic!("unreadable plan with probe history must fail closed"),
}
}
#[test]
fn a_refusal_where_nothing_ran_still_records_probe_history() {
let tmp = tempfile::tempdir().unwrap();
let plan = tmp.path().join("plan.md");
std::fs::write(
&plan,
fm("done_probes:\n - echo a\n - echo b\n - echo c\n - echo d"),
)
.unwrap();
let events = tmp.path().join("events.jsonl");
let ProbeGate::Fail { results, .. } = evaluate_done_probes(
plan.to_str(),
None,
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) else {
panic!("over-cap must refuse");
};
std::fs::write(
&events,
format!(
"{}\n",
serde_json::json!({
"type": "loop_check",
"data": {"session_id": "s1", "done_probes": results}
})
),
)
.unwrap();
assert!(
prior_fires_declared_probes(&events, "s1"),
"a declared-but-never-ran refusal must be visible as probe history"
);
}
#[test]
fn relative_plan_path_resolves_against_the_session_cwd() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("plan.md"), fm("done_probes:\n - exit 0")).unwrap();
let events = tmp.path().join("events.jsonl");
assert!(
matches!(
evaluate_done_probes(
Some("plan.md"),
None,
tmp.path(),
&events,
"s1",
Duration::from_secs(10)
),
ProbeGate::Pass(_)
),
"a relative plan_path must resolve against cwd, not the process cwd"
);
}
#[test]
fn timeout_reaches_the_gate_reason() {
let tmp = tempfile::tempdir().unwrap();
let plan = tmp.path().join("plan.md");
std::fs::write(&plan, fm("done_probes:\n - sleep 30")).unwrap();
let events = tmp.path().join("events.jsonl");
match evaluate_done_probes(
plan.to_str(),
None,
tmp.path(),
&events,
"s1",
Duration::from_millis(200),
) {
ProbeGate::Fail { reason, results } => {
assert!(
reason.contains("timed out"),
"reason names the timeout: {reason}"
);
assert_eq!(results["sleep 30"], "timeout");
}
_ => panic!("a hanging probe must refuse done"),
}
}
#[test]
fn a_pipeline_probe_timeout_does_not_hang_the_gate() {
let tmp = tempfile::tempdir().unwrap();
let start = std::time::Instant::now();
let outcome = run_probe("sleep 30 | cat", tmp.path(), Duration::from_millis(200));
assert_eq!(outcome.render(), "timeout");
assert!(
start.elapsed() < Duration::from_secs(10),
"a pipeline probe must not outlive its timeout (took {:?})",
start.elapsed()
);
}
#[test]
fn multibyte_stderr_is_truncated_without_panicking() {
let mut s = "→".repeat(400); keep_last_on_char_boundary(&mut s, PROBE_STDERR_CAP);
assert!(s.len() <= PROBE_STDERR_CAP);
assert!(s.chars().all(|c| c == '→'), "must not split a character");
}
#[test]
fn stderr_cap_keeps_the_tail_where_the_error_is() {
let mut s = format!("{}\nthe actual error", "noise ".repeat(200));
keep_last_on_char_boundary(&mut s, PROBE_STDERR_CAP);
assert!(
s.ends_with("the actual error"),
"the last line is the diagnostic; keeping the prefix drops it: {s}"
);
}
#[test]
fn block_scalar_escapes_decode_to_the_command_the_plan_meant() {
let doc = fm("done_probes:\n - \"test -n \\\"$(echo hi)\\\"\"");
assert_eq!(probes_of(&doc), vec![r#"test -n "$(echo hi)""#.to_string()]);
}
#[test]
fn single_quoted_scalar_undoubles_its_quote() {
let doc = fm("done_probes:\n - 'echo it''s fine'");
assert_eq!(probes_of(&doc), vec!["echo it's fine".to_string()]);
}
#[test]
fn plan_path_fragment_is_stripped_before_reading() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("plan.md"), fm("done_probes:\n - exit 0")).unwrap();
let events = tmp.path().join("events.jsonl");
assert!(
matches!(
evaluate_done_probes(
Some("plan.md#wave-1"),
None,
tmp.path(),
&events,
"s1",
Duration::from_secs(10)
),
ProbeGate::Pass(_)
),
"a fragment in plan_path must not silently disable the gate"
);
}
#[test]
fn a_backgrounding_probe_does_not_block_the_drain() {
let tmp = tempfile::tempdir().unwrap();
let start = std::time::Instant::now();
let outcome = run_probe("sleep 300 & exit 0", tmp.path(), Duration::from_secs(30));
assert_eq!(outcome.render(), "pass");
assert!(
start.elapsed() < Duration::from_secs(10),
"a backgrounded descendant must not hold the drain open (took {:?})",
start.elapsed()
);
}
fn project(cmds: &[&str]) -> Result<Vec<String>, String> {
Ok(cmds.iter().map(|c| c.to_string()).collect())
}
fn bare_plan(dir: &Path) -> std::path::PathBuf {
let plan = dir.join("plan.md");
std::fs::write(&plan, fm("title: p")).unwrap();
plan
}
#[test]
fn a_project_probe_gates_a_plan_that_declares_none() {
let tmp = tempfile::tempdir().unwrap();
let plan = bare_plan(tmp.path());
let events = tmp.path().join("events.jsonl");
match evaluate_done_probes(
plan.to_str(),
Some(&project(&["true"])),
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Pass(results) => assert_eq!(results["true"], "pass"),
_ => panic!("a passing project probe must let the gate through"),
}
}
#[test]
fn a_failing_project_probe_blocks_and_names_its_source() {
let tmp = tempfile::tempdir().unwrap();
let plan = bare_plan(tmp.path());
let events = tmp.path().join("events.jsonl");
match evaluate_done_probes(
plan.to_str(),
Some(&project(&["false"])),
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Fail { reason, .. } => assert!(
reason.contains("project probe `false`"),
"the reason must name the source: {reason}"
),
_ => panic!("a failing project probe must block"),
}
}
#[test]
fn an_unparseable_project_declaration_blocks_rather_than_degrading() {
let tmp = tempfile::tempdir().unwrap();
let plan = bare_plan(tmp.path());
let events = tmp.path().join("events.jsonl");
let junk: Result<Vec<String>, String> = value_as_probe_list(
&"done_probes = { a = 1 }".parse::<toml::Value>().unwrap()["done_probes"],
);
assert!(junk.is_err(), "a mapping is not a probe list");
match evaluate_done_probes(
plan.to_str(),
Some(&junk),
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Fail { reason, results } => {
assert!(
reason.contains("undeterminable"),
"must use the plan side's vocabulary: {reason}"
);
assert_eq!(results["_undeterminable"], "unparseable-config-declaration");
}
_ => panic!("an unreadable project declaration must block"),
}
}
#[test]
fn the_cap_is_per_source_not_per_union() {
let tmp = tempfile::tempdir().unwrap();
let plan = tmp.path().join("plan.md");
std::fs::write(
&plan,
fm("done_probes:\n - echo d\n - echo e\n - echo f"),
)
.unwrap();
let events = tmp.path().join("events.jsonl");
match evaluate_done_probes(
plan.to_str(),
Some(&project(&["echo a", "echo b", "echo c"])),
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Pass(results) => assert_eq!(
results.as_object().unwrap().len(),
6,
"all six probes must run: {results}"
),
other => panic!(
"3 + 3 is within the per-source cap: {}",
match other {
ProbeGate::Fail { reason, .. } => reason,
_ => "Absent".to_string(),
}
),
}
match evaluate_done_probes(
plan.to_str(),
Some(&project(&["true", "true", "true", "true"])),
tmp.path(),
&events,
"s1",
Duration::from_secs(10),
) {
ProbeGate::Fail { reason, .. } => assert!(
reason.contains("config.toml declares 4") && reason.contains("per source"),
"an over-cap project list must refuse loudly: {reason}"
),
_ => panic!("4 project probes must refuse"),
}
}
#[test]
fn no_declaration_on_either_source_stays_absent() {
let tmp = tempfile::tempdir().unwrap();
let plan = bare_plan(tmp.path());
let events = tmp.path().join("events.jsonl");
assert!(matches!(
evaluate_done_probes(
plan.to_str(),
Some(&project(&[])),
tmp.path(),
&events,
"s1",
Duration::from_secs(10)
),
ProbeGate::Absent
));
}
#[test]
fn config_done_probes_parses_off_the_flat_root() {
let s = parse_settings("done_probes = [\"make a11y-check\"]\n");
assert_eq!(s.done_probes, Some(Ok(vec!["make a11y-check".to_string()])),);
assert_eq!(parse_settings("plans_dir = \"x\"\n").done_probes, None);
assert!(parse_settings("done_probes = \"nope\"\n")
.done_probes
.unwrap()
.is_err());
assert!(parse_settings("done_probes = [1]\n")
.done_probes
.unwrap()
.is_err());
}
}