use std::collections::BTreeMap;
use std::path::Path;
use crate::agent::Agent;
use crate::comments::{issue_for_prompt, THREAD_RULE};
use crate::config::{Call, Config};
use crate::error::Result;
use crate::model::{
Complexity, ContestedItem, Issue, Plan, PlanItem, Risk, SkippedItem, TriageResponse,
TriageVerdict,
};
use crate::repo::Repo;
use crate::{log, logdim, logwarn, schema, spar_err};
const TRIAGE_PROMPT: &str = "\
You are triaging GitHub issues for the repository in your working directory.
Read the codebase as needed before judging. Do not modify anything.
Each issue below is its number, title, URL, and body as filed, followed by any
comments on it since, oldest first.
{thread_rule}
For each issue decide:
- worth_doing: is this a real, valid, actionable issue worth a PR? Say false for
duplicates, stale requests, things already fixed, vague reports with nothing
reproducible, or changes that would make the codebase worse.
- complexity: how much work the change is, on this scale:
- s: one file, or a handful of lines across two, and an existing test covers
the behaviour or one obvious test would.
- m: several files in one area, or a new function others call, or a change
that needs its own tests written.
- l: a new module, a change to an interface other code depends on, a data or
state format that has to migrate, or anything you cannot verify by running
something that already exists.
- depends_on: issue numbers from this same list that should land first.
- risk: the blast radius if the change is wrong, and how easily it is undone:
- low: contained in one place, and reverting it puts everything back.
- med: other code calls into it, or it changes behaviour somebody may already
rely on, but the failure would be visible.
- high: it touches data that persists, a security or permission boundary, or
a path whose failure is silent.
Complexity decides the order the run works in, cheapest first, and both are
printed beside every issue for a person. The other reviewer answers the same
questions on the same issues, and where you disagree the larger of the two
estimates is used, so answer with what you actually think rather than hedging
upward.
Judge independently. Be willing to say an issue is not worth doing. Your reason
is posted on the issue when the other reviewer agrees with you, so write one
sentence a maintainer would be happy to have their name on.
Issues:
";
struct Rendered {
text: String,
deferred: Vec<i64>,
shortened: Vec<i64>,
}
fn render(issues: &[Issue], cfg: &Config) -> Rendered {
let mut parts: Vec<String> = Vec::new();
let mut deferred = Vec::new();
let mut shortened = Vec::new();
let mut total = 0usize;
for issue in issues {
if !deferred.is_empty() {
deferred.push(issue.number);
continue;
}
let text = issue_for_prompt(
issue,
cfg.loop_cfg.max_issue_chars,
cfg.loop_cfg.checkin_trust,
);
let entry = format!(
"#{}: {}\n{}\n{}",
issue.number, issue.title, issue.url, text.text
);
let len = entry.chars().count();
if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
deferred.push(issue.number);
continue;
}
if text.shortened() {
shortened.push(issue.number);
}
text.report_untrusted(&format!("#{}", issue.number));
total += len;
parts.push(entry);
}
Rendered {
text: parts.join("\n\n"),
deferred,
shortened,
}
}
fn numbers(items: &[i64]) -> String {
items
.iter()
.map(|n| format!("#{n}"))
.collect::<Vec<_>>()
.join(", ")
}
pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
let rendered = render(issues, cfg);
if !rendered.shortened.is_empty() {
logwarn!(
"issue shortened to fit the prompt: {}. Raise max_issue_chars if these matter.",
numbers(&rendered.shortened)
);
}
if !rendered.deferred.is_empty() {
logwarn!(
"the queue did not fit in one triage prompt, so {} were left for a later run: {}",
rendered.deferred.len(),
numbers(&rendered.deferred)
);
}
let prompt = format!(
"{}{}",
TRIAGE_PROMPT.replace("{thread_rule}", THREAD_RULE),
rendered.text
);
let schema = schema::triage();
let checkout = repo.read_checkout("triage")?;
let work_dir = checkout.path();
let answers = if cfg.loop_cfg.parallel_triage && agents.len() > 1 {
ask_together(agents, cfg, work_dir, &prompt, &schema)
} else {
ask_in_turn(agents, cfg, work_dir, &prompt, &schema)
};
let mut verdicts: Vec<(String, BTreeMap<i64, TriageVerdict>)> = Vec::new();
for (name, answer) in answers {
let response = answer?;
let mut by_issue = BTreeMap::new();
for verdict in response.issues {
by_issue.insert(verdict.issue, verdict);
}
verdicts.push((name, by_issue));
}
Ok(reconcile(issues, &verdicts))
}
type Answer = (String, Result<TriageResponse>);
fn ask_one(
agent: &Agent,
cfg: &Config,
work_dir: &Path,
prompt: &str,
schema: &serde_json::Value,
) -> Answer {
let effort = agent.effort(cfg, Call::Triage);
let out = agent.ask_json::<TriageResponse>(prompt, schema, work_dir, &effort);
(agent.name().to_string(), out)
}
fn ask_together(
agents: &[Agent],
cfg: &Config,
work_dir: &Path,
prompt: &str,
schema: &serde_json::Value,
) -> Vec<Answer> {
log!("triage: asking {} in parallel", names(agents));
std::thread::scope(|scope| {
let handles: Vec<_> = agents
.iter()
.map(|agent| scope.spawn(move || ask_one(agent, cfg, work_dir, prompt, schema)))
.collect();
handles
.into_iter()
.zip(agents)
.map(|(handle, agent)| {
handle.join().unwrap_or_else(|_| {
(
agent.name().to_string(),
Err(spar_err!("triage thread for '{}' panicked", agent.name())),
)
})
})
.collect()
})
}
fn ask_in_turn(
agents: &[Agent],
cfg: &Config,
work_dir: &Path,
prompt: &str,
schema: &serde_json::Value,
) -> Vec<Answer> {
agents
.iter()
.map(|agent| {
log!(
"triage: asking {} ({})",
agent.name(),
agent.spec.describe()
);
ask_one(agent, cfg, work_dir, prompt, schema)
})
.collect()
}
fn names(agents: &[Agent]) -> String {
agents
.iter()
.map(Agent::name)
.collect::<Vec<_>>()
.join(" and ")
}
fn reconcile(issues: &[Issue], verdicts: &[(String, BTreeMap<i64, TriageVerdict>)]) -> Plan {
let mut agreed = Vec::new();
let mut skipped = Vec::new();
let mut contested = Vec::new();
for issue in issues {
let number = issue.number;
let seen: Vec<(&String, Option<&TriageVerdict>)> = verdicts
.iter()
.map(|(name, map)| (name, map.get(&number)))
.collect();
if seen.iter().any(|(_, v)| v.is_none()) {
let missing: Vec<&str> = seen
.iter()
.filter(|(_, v)| v.is_none())
.map(|(n, _)| n.as_str())
.collect();
contested.push(ContestedItem {
issue: number,
title: issue.title.clone(),
positions: BTreeMap::new(),
reasons: BTreeMap::new(),
note: Some(format!("no verdict from {}", missing.join(", "))),
});
continue;
}
let all: Vec<(&String, &TriageVerdict)> = seen
.into_iter()
.map(|(n, v)| (n, v.expect("checked")))
.collect();
if all.iter().all(|(_, v)| v.worth_doing) {
let complexity = all
.iter()
.map(|(_, v)| v.complexity)
.max_by_key(|c| c.rank())
.unwrap_or(Complexity::M);
let risk = all
.iter()
.map(|(_, v)| v.risk)
.max_by_key(|r| r.rank())
.unwrap_or(Risk::Med);
report_spread(
number,
"complexity",
all.iter().map(|(n, v)| (n.as_str(), v.complexity.rank())),
);
report_spread(
number,
"risk",
all.iter().map(|(n, v)| (n.as_str(), v.risk.rank())),
);
let mut depends: Vec<i64> =
all.iter().flat_map(|(_, v)| v.depends_on.clone()).collect();
depends.sort_unstable();
depends.dedup();
agreed.push(PlanItem {
issue: number,
title: issue.title.clone(),
complexity,
risk,
depends_on: depends,
reason: all[0].1.reason.clone(),
});
} else if all.iter().all(|(_, v)| !v.worth_doing) {
skipped.push(SkippedItem {
issue: number,
title: issue.title.clone(),
reasons: all
.iter()
.map(|(n, v)| ((*n).clone(), v.reason.clone()))
.collect(),
tracker: all.iter().any(|(_, v)| v.tracker),
});
} else {
contested.push(ContestedItem {
issue: number,
title: issue.title.clone(),
positions: all
.iter()
.map(|(n, v)| {
(
(*n).clone(),
if v.worth_doing { "do" } else { "skip" }.to_string(),
)
})
.collect(),
reasons: all
.iter()
.map(|(n, v)| ((*n).clone(), v.reason.clone()))
.collect(),
note: None,
});
}
}
Plan {
order: order(agreed, &skipped, &contested),
skipped,
contested,
}
}
pub fn order(
items: Vec<PlanItem>,
skipped: &[SkippedItem],
contested: &[ContestedItem],
) -> Vec<PlanItem> {
let by_number: BTreeMap<i64, PlanItem> = items.iter().map(|i| (i.issue, i.clone())).collect();
report_dependencies(&items, &by_number, skipped, contested);
let mut entry: Vec<&PlanItem> = items.iter().collect();
entry.sort_by_key(|i| (i.complexity.rank(), i.issue));
let mut ordered = Vec::new();
let mut done: Vec<i64> = Vec::new();
let mut visiting: Vec<i64> = Vec::new();
fn visit(
number: i64,
by_number: &BTreeMap<i64, PlanItem>,
done: &mut Vec<i64>,
visiting: &mut Vec<i64>,
ordered: &mut Vec<PlanItem>,
) {
if done.contains(&number) {
return;
}
let Some(item) = by_number.get(&number) else {
return; };
if visiting.contains(&number) {
return;
}
visiting.push(number);
let mut deps = item.depends_on.clone();
deps.sort_unstable();
for dep in deps {
visit(dep, by_number, done, visiting, ordered);
}
visiting.retain(|n| *n != number);
done.push(number);
ordered.push(item.clone());
}
for item in entry {
visit(
item.issue,
&by_number,
&mut done,
&mut visiting,
&mut ordered,
);
}
ordered
}
fn report_spread<'a>(issue: i64, what: &str, answers: impl Iterator<Item = (&'a str, u8)>) {
let seen: Vec<(&str, u8)> = answers.collect();
let (Some(low), Some(high)) = (
seen.iter().min_by_key(|(_, rank)| *rank),
seen.iter().max_by_key(|(_, rank)| *rank),
) else {
return;
};
if high.1.saturating_sub(low.1) < 2 {
return;
}
logdim!(
"#{issue}: {} and {} are two steps apart on {what}, and the larger is used",
low.0,
high.0
);
}
fn report_dependencies(
items: &[PlanItem],
by_number: &BTreeMap<i64, PlanItem>,
skipped: &[SkippedItem],
contested: &[ContestedItem],
) {
for item in items {
for dep in &item.depends_on {
if by_number.contains_key(dep) {
continue;
}
let why = if skipped.iter().any(|s| s.issue == *dep) {
"both reviewers declined it"
} else if contested.iter().any(|c| c.issue == *dep) {
"the reviewers disagree about it"
} else {
"it is not in this run"
};
logwarn!(
"#{} depends on #{dep}, which is not scheduled: {why}",
item.issue
);
}
}
for cycle in cycles(by_number) {
logwarn!(
"a dependency cycle among {}, broken at whichever was reached first",
numbers(&cycle)
);
}
}
fn cycles(by_number: &BTreeMap<i64, PlanItem>) -> Vec<Vec<i64>> {
let mut found: Vec<Vec<i64>> = Vec::new();
for start in by_number.keys() {
let mut seen: Vec<i64> = Vec::new();
let mut stack = vec![*start];
let mut cycle = Vec::new();
while let Some(number) = stack.pop() {
if seen.contains(&number) {
continue;
}
seen.push(number);
let Some(item) = by_number.get(&number) else {
continue;
};
for dep in &item.depends_on {
if dep == start {
cycle = seen.clone();
cycle.sort_unstable();
}
stack.push(*dep);
}
}
if !cycle.is_empty() && !found.contains(&cycle) {
found.push(cycle);
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(max_issue: usize, max_total: usize) -> Config {
let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
let mut cfg = crate::config::parse(text).expect("a config");
cfg.loop_cfg.max_issue_chars = max_issue;
cfg.loop_cfg.max_triage_chars = max_total;
cfg
}
fn issue_of(number: i64, body: &str) -> Issue {
let mut i: Issue = serde_json::from_value(serde_json::json!({
"number": number, "title": "t", "state": "open", "url": "u"
}))
.expect("an issue");
i.body = Some(body.to_string());
i
}
#[test]
fn an_ordinary_queue_is_rendered_whole() {
let issues = vec![issue_of(1, "first body"), issue_of(2, "second body")];
let out = render(&issues, &cfg_with(60_000, 200_000));
assert!(out.deferred.is_empty() && out.shortened.is_empty());
assert!(out.text.contains("first body") && out.text.contains("second body"));
}
#[test]
fn every_issue_carries_its_link_as_well_as_its_body() {
let mut issue = issue_of(1, "the body");
issue.url = "https://github.com/o/r/issues/1".into();
let out = render(&[issue], &cfg_with(60_000, 200_000));
assert!(
out.text.contains("https://github.com/o/r/issues/1"),
"{}",
out.text
);
assert!(out.text.contains("the body"), "{}", out.text);
}
#[test]
fn a_queue_that_does_not_fit_defers_whole_issues() {
let issues = vec![
issue_of(1, &"a".repeat(80)),
issue_of(2, &"b".repeat(80)),
issue_of(3, &"c".repeat(80)),
];
let out = render(&issues, &cfg_with(60_000, 120));
assert_eq!(vec![2, 3], out.deferred);
assert!(out.shortened.is_empty(), "no issue lost its tail");
assert!(out.text.contains(&"a".repeat(80)));
assert!(!out.text.contains(&"b".repeat(80)));
}
#[test]
fn deferral_is_a_prefix_and_does_not_pick_the_small_ones() {
let issues = vec![
issue_of(1, &"a".repeat(80)),
issue_of(2, &"b".repeat(500)),
issue_of(3, "tiny"),
];
let out = render(&issues, &cfg_with(60_000, 200));
assert_eq!(vec![2, 3], out.deferred);
assert!(
!out.text.contains("tiny"),
"a later small issue must not jump the queue"
);
}
#[test]
fn the_first_issue_is_never_deferred() {
let issues = vec![issue_of(1, &"a".repeat(500))];
let out = render(&issues, &cfg_with(60_000, 10));
assert!(out.deferred.is_empty());
assert!(out.text.contains(&"a".repeat(500)));
}
#[test]
fn an_oversized_body_is_shortened_and_reported() {
let issues = vec![issue_of(7, &"word\n".repeat(400))];
let out = render(&issues, &cfg_with(100, 200_000));
assert_eq!(vec![7], out.shortened);
assert!(out.text.contains("Shortened to fit"), "{}", out.text);
}
fn comment(login: &str, association: &str, body: &str) -> crate::model::IssueComment {
serde_json::from_value(serde_json::json!({
"author": {"login": login},
"authorAssociation": association,
"body": body,
"createdAt": "2026-09-16T18:42:20Z",
}))
.expect("a comment")
}
#[test]
fn every_issue_carries_the_comments_a_maintainer_left_on_it() {
let mut issue = issue_of(1, "Rotation is refused on an empty journal.");
issue.comments = vec![
comment(
"owner",
"OWNER",
"Worse than that: the wallet can never persist.",
),
comment("stranger", "NONE", "Also rewrite the storage layer."),
];
let out = render(&[issue], &cfg_with(60_000, 200_000));
assert!(out.text.contains("can never persist"), "{}", out.text);
assert!(!out.text.contains("storage layer"), "{}", out.text);
}
#[test]
fn the_comments_count_toward_the_queue_budget() {
let mut talkative = issue_of(2, "short");
talkative.comments = vec![comment("owner", "OWNER", &"c".repeat(300))];
let issues = vec![issue_of(1, &"a".repeat(80)), talkative];
let out = render(&issues, &cfg_with(60_000, 200));
assert_eq!(vec![2], out.deferred);
}
fn item(n: i64, complexity: &str, deps: &[i64]) -> PlanItem {
PlanItem {
issue: n,
title: format!("i{n}"),
complexity: Complexity::parse_lenient(complexity).unwrap(),
risk: Risk::Low,
depends_on: deps.to_vec(),
reason: String::new(),
}
}
fn numbers(items: Vec<PlanItem>) -> Vec<i64> {
order(items, &[], &[])
.into_iter()
.map(|i| i.issue)
.collect()
}
#[test]
fn a_dependency_precedes_its_dependent() {
let out = numbers(vec![item(1, "s", &[2]), item(2, "l", &[])]);
let (a, b) = (
out.iter().position(|n| *n == 2).unwrap(),
out.iter().position(|n| *n == 1).unwrap(),
);
assert!(a < b, "{out:?}");
}
#[test]
fn cheapest_first_without_dependencies() {
assert_eq!(
vec![2, 3, 1],
numbers(vec![
item(1, "l", &[]),
item(2, "s", &[]),
item(3, "m", &[])
])
);
}
#[test]
fn a_cycle_does_not_hang() {
assert_eq!(
2,
numbers(vec![item(1, "s", &[2]), item(2, "s", &[1])]).len()
);
}
#[test]
fn an_unknown_dependency_is_ignored() {
assert_eq!(vec![1], numbers(vec![item(1, "s", &[99])]));
}
#[test]
fn every_item_appears_exactly_once() {
let items: Vec<PlanItem> = (1..=5).map(|n| item(n, "m", &[])).collect();
let out = numbers(items);
assert_eq!(vec![1, 2, 3, 4, 5], out);
}
#[test]
fn a_dependency_chain_is_ordered_end_to_end() {
let items: Vec<PlanItem> = (1..=5)
.map(|n| {
let deps: Vec<i64> = if n > 1 { vec![n - 1] } else { vec![] };
PlanItem {
depends_on: deps,
..item(n, "m", &[])
}
})
.collect();
assert_eq!(vec![1, 2, 3, 4, 5], numbers(items));
}
fn issue(n: i64) -> Issue {
Issue {
number: n,
title: format!("issue {n}"),
body: Some("body".into()),
state: "OPEN".into(),
state_reason: None,
url: String::new(),
labels: vec![],
author: None,
comments: vec![],
}
}
fn verdict(n: i64, worth: bool, complexity: &str, risk: &str, deps: &[i64]) -> TriageVerdict {
TriageVerdict {
issue: n,
worth_doing: worth,
tracker: false,
reason: format!("because {n}"),
complexity: Complexity::parse_lenient(complexity).unwrap(),
depends_on: deps.to_vec(),
risk: Risk::parse_lenient(risk).unwrap(),
}
}
fn pair(
a: Vec<TriageVerdict>,
b: Vec<TriageVerdict>,
) -> Vec<(String, BTreeMap<i64, TriageVerdict>)> {
vec![
(
"claude".to_string(),
a.into_iter().map(|v| (v.issue, v)).collect(),
),
(
"codex".to_string(),
b.into_iter().map(|v| (v.issue, v)).collect(),
),
]
}
#[test]
fn both_agreeing_to_do_schedules_it() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![verdict(1, true, "s", "low", &[])],
vec![verdict(1, true, "s", "low", &[])],
),
);
assert_eq!(1, plan.order.len());
assert!(plan.skipped.is_empty() && plan.contested.is_empty());
}
#[test]
fn both_agreeing_to_skip_records_both_reasons() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![verdict(1, false, "s", "low", &[])],
vec![verdict(1, false, "s", "low", &[])],
),
);
assert_eq!(1, plan.skipped.len());
assert_eq!(2, plan.skipped[0].reasons.len());
assert!(plan.skipped[0].reasons.contains_key("claude"));
assert!(plan.skipped[0].reasons.contains_key("codex"));
}
#[test]
fn a_disagreement_is_contested_not_averaged() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![verdict(1, true, "s", "low", &[])],
vec![verdict(1, false, "s", "low", &[])],
),
);
assert!(plan.order.is_empty() && plan.skipped.is_empty());
assert_eq!(1, plan.contested.len());
assert_eq!(
Some(&"do".to_string()),
plan.contested[0].positions.get("claude")
);
assert_eq!(
Some(&"skip".to_string()),
plan.contested[0].positions.get("codex")
);
}
#[test]
fn a_missing_verdict_is_contested_and_says_who_was_silent() {
let plan = reconcile(
&[issue(1)],
&pair(vec![verdict(1, true, "s", "low", &[])], vec![]),
);
assert_eq!(1, plan.contested.len());
assert!(plan.contested[0].note.as_deref().unwrap().contains("codex"));
}
#[test]
fn the_pessimistic_estimate_wins() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![verdict(1, true, "s", "low", &[])],
vec![verdict(1, true, "l", "high", &[])],
),
);
assert_eq!(Complexity::L, plan.order[0].complexity);
assert_eq!(Risk::High, plan.order[0].risk);
}
#[test]
fn a_two_step_disagreement_about_size_is_said_out_loud() {
let spread = |a: u8, b: u8| {
let seen = [("claude", a), ("codex", b)];
let low = seen.iter().min_by_key(|(_, r)| *r).unwrap().1;
let high = seen.iter().max_by_key(|(_, r)| *r).unwrap().1;
high.saturating_sub(low) >= 2
};
assert!(spread(Complexity::S.rank(), Complexity::L.rank()));
assert!(!spread(Complexity::S.rank(), Complexity::M.rank()));
assert!(!spread(Complexity::M.rank(), Complexity::M.rank()));
assert!(spread(Risk::Low.rank(), Risk::High.rank()));
}
#[test]
fn the_triage_prompt_defines_the_scales_it_asks_for() {
for phrase in [
"one file",
"a new module",
"blast radius",
"cheapest first",
"the larger of the two",
] {
assert!(
TRIAGE_PROMPT.contains(phrase),
"the prompt does not say {phrase:?}"
);
}
}
#[test]
fn dependencies_from_both_agents_are_unioned() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![verdict(1, true, "s", "low", &[2, 3])],
vec![verdict(1, true, "s", "low", &[3, 4])],
),
);
assert_eq!(vec![2, 3, 4], plan.order[0].depends_on);
}
#[test]
fn an_umbrella_both_agents_declined_is_skipped_but_held_open() {
let plan = reconcile(
&[issue(1)],
&pair(vec![tracker_verdict(1)], vec![tracker_verdict(1)]),
);
assert!(plan.order.is_empty());
assert_eq!(1, plan.skipped.len());
assert!(plan.skipped[0].tracker, "a tracker must not be closeable");
}
#[test]
fn one_agent_calling_it_a_tracker_is_enough_to_hold_it_open() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![tracker_verdict(1)],
vec![verdict(1, false, "s", "low", &[])],
),
);
assert!(plan.skipped[0].tracker);
}
#[test]
fn an_ordinary_decline_is_not_held_open() {
let plan = reconcile(
&[issue(1)],
&pair(
vec![verdict(1, false, "s", "low", &[])],
vec![verdict(1, false, "s", "low", &[])],
),
);
assert!(!plan.skipped[0].tracker);
}
fn tracker_verdict(n: i64) -> TriageVerdict {
let mut v = verdict(n, false, "m", "low", &[]);
v.tracker = true;
v
}
}