Skip to main content

spar/
triage.rs

1//! Both agents judge every issue independently, then the two verdicts are
2//! reconciled mechanically.
3//!
4//! Nothing here lets one agent overrule the other. Both say do, it is
5//! scheduled. Both say skip, it is skipped and the shared reasoning is posted.
6//! They disagree, it is parked for a person, because a disagreement between two
7//! competent reviewers is information, not noise to be averaged away.
8
9use std::collections::BTreeMap;
10
11use crate::agent::Agent;
12use crate::config::Config;
13use crate::error::Result;
14use crate::model::{
15    Complexity, ContestedItem, Issue, Plan, PlanItem, Risk, SkippedItem, TriageResponse,
16    TriageVerdict,
17};
18use crate::repo::Repo;
19use crate::{log, schema, spar_err};
20
21const TRIAGE_PROMPT: &str = "\
22You are triaging GitHub issues for the repository in your working directory.
23Read the codebase as needed before judging. Do not modify anything.
24
25For each issue decide:
26- worth_doing: is this a real, valid, actionable issue worth a PR? Say false for
27  duplicates, stale requests, things already fixed, vague reports with nothing
28  reproducible, or changes that would make the codebase worse.
29- complexity: s, m, or l.
30- depends_on: issue numbers from this same list that should land first.
31- risk: how likely a change here is to break something.
32
33Judge independently. Be willing to say an issue is not worth doing. Your reason
34is posted on the issue when the other reviewer agrees with you, so write one
35sentence a maintainer would be happy to have their name on.
36
37Issues:
38";
39
40/// Ask both agents, then reconcile.
41pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
42    let rendered: String = issues
43        .iter()
44        .map(|i| {
45            let body: String = i.body_text().trim().chars().take(2000).collect();
46            format!("#{}: {}\n{body}", i.number, i.title)
47        })
48        .collect::<Vec<_>>()
49        .join("\n\n");
50    let prompt = format!("{TRIAGE_PROMPT}{rendered}");
51    let schema = schema::triage();
52
53    let answers = if cfg.loop_cfg.parallel_triage && agents.len() > 1 {
54        ask_together(agents, cfg, repo, &prompt, &schema)
55    } else {
56        ask_in_turn(agents, cfg, repo, &prompt, &schema)
57    };
58
59    let mut verdicts: Vec<(String, BTreeMap<i64, TriageVerdict>)> = Vec::new();
60    for (name, answer) in answers {
61        let response = answer?;
62        let mut by_issue = BTreeMap::new();
63        for verdict in response.issues {
64            by_issue.insert(verdict.issue, verdict);
65        }
66        verdicts.push((name, by_issue));
67    }
68
69    Ok(reconcile(issues, &verdicts))
70}
71
72type Answer = (String, Result<TriageResponse>);
73
74fn ask_one(
75    agent: &Agent,
76    cfg: &Config,
77    repo: &Repo,
78    prompt: &str,
79    schema: &serde_json::Value,
80) -> Answer {
81    let effort = cfg.effort_for_round(&agent.spec, 1);
82    let out = agent.ask_json::<TriageResponse>(prompt, schema, repo.root(), effort.as_deref());
83    (agent.name().to_string(), out)
84}
85
86/// Both agents at once. Triage only reads, so there is nothing to serialise,
87/// and a full repo pass is the slowest step in a run.
88fn ask_together(
89    agents: &[Agent],
90    cfg: &Config,
91    repo: &Repo,
92    prompt: &str,
93    schema: &serde_json::Value,
94) -> Vec<Answer> {
95    log!("triage: asking {} in parallel", names(agents));
96    std::thread::scope(|scope| {
97        let handles: Vec<_> = agents
98            .iter()
99            .map(|agent| scope.spawn(move || ask_one(agent, cfg, repo, prompt, schema)))
100            .collect();
101        handles
102            .into_iter()
103            .zip(agents)
104            .map(|(handle, agent)| {
105                handle.join().unwrap_or_else(|_| {
106                    (
107                        agent.name().to_string(),
108                        Err(spar_err!("triage thread for '{}' panicked", agent.name())),
109                    )
110                })
111            })
112            .collect()
113    })
114}
115
116fn ask_in_turn(
117    agents: &[Agent],
118    cfg: &Config,
119    repo: &Repo,
120    prompt: &str,
121    schema: &serde_json::Value,
122) -> Vec<Answer> {
123    agents
124        .iter()
125        .map(|agent| {
126            log!(
127                "triage: asking {} ({})",
128                agent.name(),
129                agent.spec.describe()
130            );
131            ask_one(agent, cfg, repo, prompt, schema)
132        })
133        .collect()
134}
135
136fn names(agents: &[Agent]) -> String {
137    agents
138        .iter()
139        .map(Agent::name)
140        .collect::<Vec<_>>()
141        .join(" and ")
142}
143
144// ---------------------------------------------------------------------------
145// Reconciliation
146// ---------------------------------------------------------------------------
147
148fn reconcile(issues: &[Issue], verdicts: &[(String, BTreeMap<i64, TriageVerdict>)]) -> Plan {
149    let mut agreed = Vec::new();
150    let mut skipped = Vec::new();
151    let mut contested = Vec::new();
152
153    for issue in issues {
154        let number = issue.number;
155        let seen: Vec<(&String, Option<&TriageVerdict>)> = verdicts
156            .iter()
157            .map(|(name, map)| (name, map.get(&number)))
158            .collect();
159
160        if seen.iter().any(|(_, v)| v.is_none()) {
161            let missing: Vec<&str> = seen
162                .iter()
163                .filter(|(_, v)| v.is_none())
164                .map(|(n, _)| n.as_str())
165                .collect();
166            contested.push(ContestedItem {
167                issue: number,
168                title: issue.title.clone(),
169                positions: BTreeMap::new(),
170                reasons: BTreeMap::new(),
171                note: Some(format!("no verdict from {}", missing.join(", "))),
172            });
173            continue;
174        }
175
176        let all: Vec<(&String, &TriageVerdict)> = seen
177            .into_iter()
178            .map(|(n, v)| (n, v.expect("checked")))
179            .collect();
180
181        if all.iter().all(|(_, v)| v.worth_doing) {
182            let complexity = all
183                .iter()
184                .map(|(_, v)| v.complexity)
185                .max_by_key(|c| c.rank())
186                .unwrap_or(Complexity::M);
187            let risk = all
188                .iter()
189                .map(|(_, v)| v.risk)
190                .max_by_key(|r| r.rank())
191                .unwrap_or(Risk::Med);
192            let mut depends: Vec<i64> =
193                all.iter().flat_map(|(_, v)| v.depends_on.clone()).collect();
194            depends.sort_unstable();
195            depends.dedup();
196            agreed.push(PlanItem {
197                issue: number,
198                title: issue.title.clone(),
199                complexity,
200                risk,
201                depends_on: depends,
202                reason: all[0].1.reason.clone(),
203            });
204        } else if all.iter().all(|(_, v)| !v.worth_doing) {
205            skipped.push(SkippedItem {
206                issue: number,
207                title: issue.title.clone(),
208                reasons: all
209                    .iter()
210                    .map(|(n, v)| ((*n).clone(), v.reason.clone()))
211                    .collect(),
212            });
213        } else {
214            contested.push(ContestedItem {
215                issue: number,
216                title: issue.title.clone(),
217                positions: all
218                    .iter()
219                    .map(|(n, v)| {
220                        (
221                            (*n).clone(),
222                            if v.worth_doing { "do" } else { "skip" }.to_string(),
223                        )
224                    })
225                    .collect(),
226                reasons: all
227                    .iter()
228                    .map(|(n, v)| ((*n).clone(), v.reason.clone()))
229                    .collect(),
230                note: None,
231            });
232        }
233    }
234
235    Plan {
236        order: order(agreed),
237        skipped,
238        contested,
239    }
240}
241
242/// Topological by dependency, then cheapest first, so blockers clear early and
243/// the large risky items inherit a healthier base.
244pub fn order(items: Vec<PlanItem>) -> Vec<PlanItem> {
245    let by_number: BTreeMap<i64, PlanItem> = items.iter().map(|i| (i.issue, i.clone())).collect();
246
247    let mut entry: Vec<&PlanItem> = items.iter().collect();
248    entry.sort_by_key(|i| (i.complexity.rank(), i.issue));
249
250    let mut ordered = Vec::new();
251    let mut done: Vec<i64> = Vec::new();
252    let mut visiting: Vec<i64> = Vec::new();
253
254    fn visit(
255        number: i64,
256        by_number: &BTreeMap<i64, PlanItem>,
257        done: &mut Vec<i64>,
258        visiting: &mut Vec<i64>,
259        ordered: &mut Vec<PlanItem>,
260    ) {
261        if done.contains(&number) {
262            return;
263        }
264        let Some(item) = by_number.get(&number) else {
265            return; // a dependency outside this run's list
266        };
267        if visiting.contains(&number) {
268            return; // dependency cycle, break it rather than hang
269        }
270        visiting.push(number);
271        let mut deps = item.depends_on.clone();
272        deps.sort_unstable();
273        for dep in deps {
274            visit(dep, by_number, done, visiting, ordered);
275        }
276        visiting.retain(|n| *n != number);
277        done.push(number);
278        ordered.push(item.clone());
279    }
280
281    for item in entry {
282        visit(
283            item.issue,
284            &by_number,
285            &mut done,
286            &mut visiting,
287            &mut ordered,
288        );
289    }
290    ordered
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    fn item(n: i64, complexity: &str, deps: &[i64]) -> PlanItem {
298        PlanItem {
299            issue: n,
300            title: format!("i{n}"),
301            complexity: Complexity::parse_lenient(complexity).unwrap(),
302            risk: Risk::Low,
303            depends_on: deps.to_vec(),
304            reason: String::new(),
305        }
306    }
307
308    fn numbers(items: Vec<PlanItem>) -> Vec<i64> {
309        order(items).into_iter().map(|i| i.issue).collect()
310    }
311
312    #[test]
313    fn a_dependency_precedes_its_dependent() {
314        let out = numbers(vec![item(1, "s", &[2]), item(2, "l", &[])]);
315        let (a, b) = (
316            out.iter().position(|n| *n == 2).unwrap(),
317            out.iter().position(|n| *n == 1).unwrap(),
318        );
319        assert!(a < b, "{out:?}");
320    }
321
322    #[test]
323    fn cheapest_first_without_dependencies() {
324        assert_eq!(
325            vec![2, 3, 1],
326            numbers(vec![
327                item(1, "l", &[]),
328                item(2, "s", &[]),
329                item(3, "m", &[])
330            ])
331        );
332    }
333
334    #[test]
335    fn a_cycle_does_not_hang() {
336        assert_eq!(
337            2,
338            numbers(vec![item(1, "s", &[2]), item(2, "s", &[1])]).len()
339        );
340    }
341
342    #[test]
343    fn an_unknown_dependency_is_ignored() {
344        assert_eq!(vec![1], numbers(vec![item(1, "s", &[99])]));
345    }
346
347    #[test]
348    fn every_item_appears_exactly_once() {
349        let items: Vec<PlanItem> = (1..=5).map(|n| item(n, "m", &[])).collect();
350        let out = numbers(items);
351        assert_eq!(vec![1, 2, 3, 4, 5], out);
352    }
353
354    #[test]
355    fn a_dependency_chain_is_ordered_end_to_end() {
356        let items: Vec<PlanItem> = (1..=5)
357            .map(|n| {
358                let deps: Vec<i64> = if n > 1 { vec![n - 1] } else { vec![] };
359                PlanItem {
360                    depends_on: deps,
361                    ..item(n, "m", &[])
362                }
363            })
364            .collect();
365        assert_eq!(vec![1, 2, 3, 4, 5], numbers(items));
366    }
367
368    // -- reconciliation --------------------------------------------------
369
370    fn issue(n: i64) -> Issue {
371        Issue {
372            number: n,
373            title: format!("issue {n}"),
374            body: Some("body".into()),
375            state: "OPEN".into(),
376            url: String::new(),
377            labels: vec![],
378        }
379    }
380
381    fn verdict(n: i64, worth: bool, complexity: &str, risk: &str, deps: &[i64]) -> TriageVerdict {
382        TriageVerdict {
383            issue: n,
384            worth_doing: worth,
385            reason: format!("because {n}"),
386            complexity: Complexity::parse_lenient(complexity).unwrap(),
387            depends_on: deps.to_vec(),
388            risk: Risk::parse_lenient(risk).unwrap(),
389        }
390    }
391
392    fn pair(
393        a: Vec<TriageVerdict>,
394        b: Vec<TriageVerdict>,
395    ) -> Vec<(String, BTreeMap<i64, TriageVerdict>)> {
396        vec![
397            (
398                "claude".to_string(),
399                a.into_iter().map(|v| (v.issue, v)).collect(),
400            ),
401            (
402                "codex".to_string(),
403                b.into_iter().map(|v| (v.issue, v)).collect(),
404            ),
405        ]
406    }
407
408    #[test]
409    fn both_agreeing_to_do_schedules_it() {
410        let plan = reconcile(
411            &[issue(1)],
412            &pair(
413                vec![verdict(1, true, "s", "low", &[])],
414                vec![verdict(1, true, "s", "low", &[])],
415            ),
416        );
417        assert_eq!(1, plan.order.len());
418        assert!(plan.skipped.is_empty() && plan.contested.is_empty());
419    }
420
421    #[test]
422    fn both_agreeing_to_skip_records_both_reasons() {
423        let plan = reconcile(
424            &[issue(1)],
425            &pair(
426                vec![verdict(1, false, "s", "low", &[])],
427                vec![verdict(1, false, "s", "low", &[])],
428            ),
429        );
430        assert_eq!(1, plan.skipped.len());
431        assert_eq!(2, plan.skipped[0].reasons.len());
432        assert!(plan.skipped[0].reasons.contains_key("claude"));
433        assert!(plan.skipped[0].reasons.contains_key("codex"));
434    }
435
436    /// One agent never overrules the other. A split goes to a person.
437    #[test]
438    fn a_disagreement_is_contested_not_averaged() {
439        let plan = reconcile(
440            &[issue(1)],
441            &pair(
442                vec![verdict(1, true, "s", "low", &[])],
443                vec![verdict(1, false, "s", "low", &[])],
444            ),
445        );
446        assert!(plan.order.is_empty() && plan.skipped.is_empty());
447        assert_eq!(1, plan.contested.len());
448        assert_eq!(
449            Some(&"do".to_string()),
450            plan.contested[0].positions.get("claude")
451        );
452        assert_eq!(
453            Some(&"skip".to_string()),
454            plan.contested[0].positions.get("codex")
455        );
456    }
457
458    #[test]
459    fn a_missing_verdict_is_contested_and_says_who_was_silent() {
460        let plan = reconcile(
461            &[issue(1)],
462            &pair(vec![verdict(1, true, "s", "low", &[])], vec![]),
463        );
464        assert_eq!(1, plan.contested.len());
465        assert!(plan.contested[0].note.as_deref().unwrap().contains("codex"));
466    }
467
468    #[test]
469    fn the_pessimistic_estimate_wins() {
470        let plan = reconcile(
471            &[issue(1)],
472            &pair(
473                vec![verdict(1, true, "s", "low", &[])],
474                vec![verdict(1, true, "l", "high", &[])],
475            ),
476        );
477        assert_eq!(Complexity::L, plan.order[0].complexity);
478        assert_eq!(Risk::High, plan.order[0].risk);
479    }
480
481    #[test]
482    fn dependencies_from_both_agents_are_unioned() {
483        let plan = reconcile(
484            &[issue(1)],
485            &pair(
486                vec![verdict(1, true, "s", "low", &[2, 3])],
487                vec![verdict(1, true, "s", "low", &[3, 4])],
488            ),
489        );
490        assert_eq!(vec![2, 3, 4], plan.order[0].depends_on);
491    }
492}