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, logwarn, 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
25Each issue below is its number, title, URL, and body as filed. The discussion
26since it was filed is not included. Where a body leaves the judgement genuinely
27unclear, read that one issue's thread before deciding; read the ones that need
28it rather than all of them, because the queue is long and most will not. If you
29cannot reach the network, judge on what is here.
30
31For each issue decide:
32- worth_doing: is this a real, valid, actionable issue worth a PR? Say false for
33  duplicates, stale requests, things already fixed, vague reports with nothing
34  reproducible, or changes that would make the codebase worse.
35- complexity: s, m, or l.
36- depends_on: issue numbers from this same list that should land first.
37- risk: how likely a change here is to break something.
38
39Judge independently. Be willing to say an issue is not worth doing. Your reason
40is posted on the issue when the other reviewer agrees with you, so write one
41sentence a maintainer would be happy to have their name on.
42
43Issues:
44";
45
46/// Every issue as the prompt carries it, and what would not fit.
47struct Rendered {
48    text: String,
49    /// Left for a later run, because the queue did not fit in one prompt.
50    deferred: Vec<i64>,
51    /// Included, but with the tail of the body left off.
52    shortened: Vec<i64>,
53}
54
55/// Render the queue, under two budgets that do different jobs.
56///
57/// One issue is shortened only past `max_issue_chars`, which nothing a person
58/// wrote reaches. The queue as a whole is bounded by `max_triage_chars`,
59/// because triage reads every open issue at once and the queue is the only
60/// unbounded thing here.
61///
62/// Past that, whole issues are left for the next run rather than every issue
63/// losing its tail. A triage verdict is posted on the issue and can close it,
64/// so judging one on part of what it says is worse than not having reached it
65/// yet. Everything from the first issue that does not fit is deferred together,
66/// so what was read is always a prefix of the queue rather than whichever
67/// issues happened to be small.
68fn render(issues: &[Issue], cfg: &Config) -> Rendered {
69    let mut parts: Vec<String> = Vec::new();
70    let mut deferred = Vec::new();
71    let mut shortened = Vec::new();
72    let mut total = 0usize;
73
74    for issue in issues {
75        if !deferred.is_empty() {
76            deferred.push(issue.number);
77            continue;
78        }
79        let (body, cut) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
80        // The URL is what makes the comments reachable to an agent that can
81        // reach them, without spar fetching every thread in the queue on the
82        // chance one of them matters.
83        let entry = format!("#{}: {}\n{}\n{body}", issue.number, issue.title, issue.url);
84        let len = entry.chars().count();
85        // The first issue goes in whatever its size. A queue of one that does
86        // not fit is a run that does nothing, forever.
87        if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
88            deferred.push(issue.number);
89            continue;
90        }
91        if cut {
92            shortened.push(issue.number);
93        }
94        total += len;
95        parts.push(entry);
96    }
97
98    Rendered {
99        text: parts.join("\n\n"),
100        deferred,
101        shortened,
102    }
103}
104
105fn numbers(items: &[i64]) -> String {
106    items
107        .iter()
108        .map(|n| format!("#{n}"))
109        .collect::<Vec<_>>()
110        .join(", ")
111}
112
113/// Ask both agents, then reconcile.
114pub fn triage(agents: &[Agent], cfg: &Config, repo: &Repo, issues: &[Issue]) -> Result<Plan> {
115    let rendered = render(issues, cfg);
116    // Never silently. An agent cannot report a gap it was not told about, and
117    // a verdict on part of an issue looks exactly like a verdict on all of it.
118    if !rendered.shortened.is_empty() {
119        logwarn!(
120            "issue body shortened to fit the prompt: {}. Raise max_issue_chars if these matter.",
121            numbers(&rendered.shortened)
122        );
123    }
124    if !rendered.deferred.is_empty() {
125        logwarn!(
126            "the queue did not fit in one triage prompt, so {} were left for a later run: {}",
127            rendered.deferred.len(),
128            numbers(&rendered.deferred)
129        );
130    }
131    let prompt = format!("{TRIAGE_PROMPT}{}", rendered.text);
132    let schema = schema::triage();
133
134    let answers = if cfg.loop_cfg.parallel_triage && agents.len() > 1 {
135        ask_together(agents, cfg, repo, &prompt, &schema)
136    } else {
137        ask_in_turn(agents, cfg, repo, &prompt, &schema)
138    };
139
140    let mut verdicts: Vec<(String, BTreeMap<i64, TriageVerdict>)> = Vec::new();
141    for (name, answer) in answers {
142        let response = answer?;
143        let mut by_issue = BTreeMap::new();
144        for verdict in response.issues {
145            by_issue.insert(verdict.issue, verdict);
146        }
147        verdicts.push((name, by_issue));
148    }
149
150    Ok(reconcile(issues, &verdicts))
151}
152
153type Answer = (String, Result<TriageResponse>);
154
155fn ask_one(
156    agent: &Agent,
157    cfg: &Config,
158    repo: &Repo,
159    prompt: &str,
160    schema: &serde_json::Value,
161) -> Answer {
162    let effort = cfg.effort_for_round(&agent.spec, 1);
163    let out = agent.ask_json::<TriageResponse>(prompt, schema, repo.root(), effort.as_deref());
164    (agent.name().to_string(), out)
165}
166
167/// Both agents at once. Triage only reads, so there is nothing to serialise,
168/// and a full repo pass is the slowest step in a run.
169fn ask_together(
170    agents: &[Agent],
171    cfg: &Config,
172    repo: &Repo,
173    prompt: &str,
174    schema: &serde_json::Value,
175) -> Vec<Answer> {
176    log!("triage: asking {} in parallel", names(agents));
177    std::thread::scope(|scope| {
178        let handles: Vec<_> = agents
179            .iter()
180            .map(|agent| scope.spawn(move || ask_one(agent, cfg, repo, prompt, schema)))
181            .collect();
182        handles
183            .into_iter()
184            .zip(agents)
185            .map(|(handle, agent)| {
186                handle.join().unwrap_or_else(|_| {
187                    (
188                        agent.name().to_string(),
189                        Err(spar_err!("triage thread for '{}' panicked", agent.name())),
190                    )
191                })
192            })
193            .collect()
194    })
195}
196
197fn ask_in_turn(
198    agents: &[Agent],
199    cfg: &Config,
200    repo: &Repo,
201    prompt: &str,
202    schema: &serde_json::Value,
203) -> Vec<Answer> {
204    agents
205        .iter()
206        .map(|agent| {
207            log!(
208                "triage: asking {} ({})",
209                agent.name(),
210                agent.spec.describe()
211            );
212            ask_one(agent, cfg, repo, prompt, schema)
213        })
214        .collect()
215}
216
217fn names(agents: &[Agent]) -> String {
218    agents
219        .iter()
220        .map(Agent::name)
221        .collect::<Vec<_>>()
222        .join(" and ")
223}
224
225// ---------------------------------------------------------------------------
226// Reconciliation
227// ---------------------------------------------------------------------------
228
229fn reconcile(issues: &[Issue], verdicts: &[(String, BTreeMap<i64, TriageVerdict>)]) -> Plan {
230    let mut agreed = Vec::new();
231    let mut skipped = Vec::new();
232    let mut contested = Vec::new();
233
234    for issue in issues {
235        let number = issue.number;
236        let seen: Vec<(&String, Option<&TriageVerdict>)> = verdicts
237            .iter()
238            .map(|(name, map)| (name, map.get(&number)))
239            .collect();
240
241        if seen.iter().any(|(_, v)| v.is_none()) {
242            let missing: Vec<&str> = seen
243                .iter()
244                .filter(|(_, v)| v.is_none())
245                .map(|(n, _)| n.as_str())
246                .collect();
247            contested.push(ContestedItem {
248                issue: number,
249                title: issue.title.clone(),
250                positions: BTreeMap::new(),
251                reasons: BTreeMap::new(),
252                note: Some(format!("no verdict from {}", missing.join(", "))),
253            });
254            continue;
255        }
256
257        let all: Vec<(&String, &TriageVerdict)> = seen
258            .into_iter()
259            .map(|(n, v)| (n, v.expect("checked")))
260            .collect();
261
262        if all.iter().all(|(_, v)| v.worth_doing) {
263            let complexity = all
264                .iter()
265                .map(|(_, v)| v.complexity)
266                .max_by_key(|c| c.rank())
267                .unwrap_or(Complexity::M);
268            let risk = all
269                .iter()
270                .map(|(_, v)| v.risk)
271                .max_by_key(|r| r.rank())
272                .unwrap_or(Risk::Med);
273            let mut depends: Vec<i64> =
274                all.iter().flat_map(|(_, v)| v.depends_on.clone()).collect();
275            depends.sort_unstable();
276            depends.dedup();
277            agreed.push(PlanItem {
278                issue: number,
279                title: issue.title.clone(),
280                complexity,
281                risk,
282                depends_on: depends,
283                reason: all[0].1.reason.clone(),
284            });
285        } else if all.iter().all(|(_, v)| !v.worth_doing) {
286            skipped.push(SkippedItem {
287                issue: number,
288                title: issue.title.clone(),
289                reasons: all
290                    .iter()
291                    .map(|(n, v)| ((*n).clone(), v.reason.clone()))
292                    .collect(),
293                // Either agent is enough. Closing needs both to agree, so one
294                // saying the issue is not finished is enough to withhold it.
295                tracker: all.iter().any(|(_, v)| v.tracker),
296            });
297        } else {
298            contested.push(ContestedItem {
299                issue: number,
300                title: issue.title.clone(),
301                positions: all
302                    .iter()
303                    .map(|(n, v)| {
304                        (
305                            (*n).clone(),
306                            if v.worth_doing { "do" } else { "skip" }.to_string(),
307                        )
308                    })
309                    .collect(),
310                reasons: all
311                    .iter()
312                    .map(|(n, v)| ((*n).clone(), v.reason.clone()))
313                    .collect(),
314                note: None,
315            });
316        }
317    }
318
319    Plan {
320        order: order(agreed),
321        skipped,
322        contested,
323    }
324}
325
326/// Topological by dependency, then cheapest first, so blockers clear early and
327/// the large risky items inherit a healthier base.
328pub fn order(items: Vec<PlanItem>) -> Vec<PlanItem> {
329    let by_number: BTreeMap<i64, PlanItem> = items.iter().map(|i| (i.issue, i.clone())).collect();
330
331    let mut entry: Vec<&PlanItem> = items.iter().collect();
332    entry.sort_by_key(|i| (i.complexity.rank(), i.issue));
333
334    let mut ordered = Vec::new();
335    let mut done: Vec<i64> = Vec::new();
336    let mut visiting: Vec<i64> = Vec::new();
337
338    fn visit(
339        number: i64,
340        by_number: &BTreeMap<i64, PlanItem>,
341        done: &mut Vec<i64>,
342        visiting: &mut Vec<i64>,
343        ordered: &mut Vec<PlanItem>,
344    ) {
345        if done.contains(&number) {
346            return;
347        }
348        let Some(item) = by_number.get(&number) else {
349            return; // a dependency outside this run's list
350        };
351        if visiting.contains(&number) {
352            return; // dependency cycle, break it rather than hang
353        }
354        visiting.push(number);
355        let mut deps = item.depends_on.clone();
356        deps.sort_unstable();
357        for dep in deps {
358            visit(dep, by_number, done, visiting, ordered);
359        }
360        visiting.retain(|n| *n != number);
361        done.push(number);
362        ordered.push(item.clone());
363    }
364
365    for item in entry {
366        visit(
367            item.issue,
368            &by_number,
369            &mut done,
370            &mut visiting,
371            &mut ordered,
372        );
373    }
374    ordered
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn cfg_with(max_issue: usize, max_total: usize) -> Config {
382        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
383        let mut cfg = crate::config::parse(text).expect("a config");
384        cfg.loop_cfg.max_issue_chars = max_issue;
385        cfg.loop_cfg.max_triage_chars = max_total;
386        cfg
387    }
388
389    fn issue_of(number: i64, body: &str) -> Issue {
390        let mut i: Issue = serde_json::from_value(serde_json::json!({
391            "number": number, "title": "t", "state": "open", "url": "u"
392        }))
393        .expect("an issue");
394        i.body = Some(body.to_string());
395        i
396    }
397
398    /// The case that is every real queue. Nothing is cut, nothing is deferred,
399    /// and every issue reaches the prompt entire.
400    #[test]
401    fn an_ordinary_queue_is_rendered_whole() {
402        let issues = vec![issue_of(1, "first body"), issue_of(2, "second body")];
403        let out = render(&issues, &cfg_with(60_000, 200_000));
404        assert!(out.deferred.is_empty() && out.shortened.is_empty());
405        assert!(out.text.contains("first body") && out.text.contains("second body"));
406    }
407
408    /// The body is what an agent judges on, and the link is how one that can
409    /// reach the network reads the discussion spar does not fetch. Both, not
410    /// either: codex has no network under the sandbox spar runs it in, so a
411    /// link alone would leave it judging the title.
412    #[test]
413    fn every_issue_carries_its_link_as_well_as_its_body() {
414        let mut issue = issue_of(1, "the body");
415        issue.url = "https://github.com/o/r/issues/1".into();
416        let out = render(&[issue], &cfg_with(60_000, 200_000));
417        assert!(
418            out.text.contains("https://github.com/o/r/issues/1"),
419            "{}",
420            out.text
421        );
422        assert!(out.text.contains("the body"), "{}", out.text);
423    }
424
425    /// A verdict is posted on the issue and can close it, so an issue judged on
426    /// part of its body is worse than one not reached yet. Past the budget,
427    /// whole issues wait rather than every issue losing its tail.
428    #[test]
429    fn a_queue_that_does_not_fit_defers_whole_issues() {
430        let issues = vec![
431            issue_of(1, &"a".repeat(80)),
432            issue_of(2, &"b".repeat(80)),
433            issue_of(3, &"c".repeat(80)),
434        ];
435        let out = render(&issues, &cfg_with(60_000, 120));
436        assert_eq!(vec![2, 3], out.deferred);
437        assert!(out.shortened.is_empty(), "no issue lost its tail");
438        assert!(out.text.contains(&"a".repeat(80)));
439        assert!(!out.text.contains(&"b".repeat(80)));
440    }
441
442    /// Everything from the first issue that does not fit is deferred together,
443    /// so what was read is a prefix of the queue rather than whichever issues
444    /// happened to be small enough to slot in.
445    #[test]
446    fn deferral_is_a_prefix_and_does_not_pick_the_small_ones() {
447        let issues = vec![
448            issue_of(1, &"a".repeat(80)),
449            issue_of(2, &"b".repeat(500)),
450            issue_of(3, "tiny"),
451        ];
452        let out = render(&issues, &cfg_with(60_000, 200));
453        assert_eq!(vec![2, 3], out.deferred);
454        assert!(
455            !out.text.contains("tiny"),
456            "a later small issue must not jump the queue"
457        );
458    }
459
460    /// A queue of one that does not fit would be a run that does nothing,
461    /// forever, so the first issue goes in whatever its size.
462    #[test]
463    fn the_first_issue_is_never_deferred() {
464        let issues = vec![issue_of(1, &"a".repeat(500))];
465        let out = render(&issues, &cfg_with(60_000, 10));
466        assert!(out.deferred.is_empty());
467        assert!(out.text.contains(&"a".repeat(500)));
468    }
469
470    /// Past the per issue budget the body is shortened and the issue is named,
471    /// rather than the queue quietly carrying a fragment.
472    #[test]
473    fn an_oversized_body_is_shortened_and_reported() {
474        let issues = vec![issue_of(7, &"word\n".repeat(400))];
475        let out = render(&issues, &cfg_with(100, 200_000));
476        assert_eq!(vec![7], out.shortened);
477        assert!(out.text.contains("Shortened to fit"), "{}", out.text);
478    }
479
480    fn item(n: i64, complexity: &str, deps: &[i64]) -> PlanItem {
481        PlanItem {
482            issue: n,
483            title: format!("i{n}"),
484            complexity: Complexity::parse_lenient(complexity).unwrap(),
485            risk: Risk::Low,
486            depends_on: deps.to_vec(),
487            reason: String::new(),
488        }
489    }
490
491    fn numbers(items: Vec<PlanItem>) -> Vec<i64> {
492        order(items).into_iter().map(|i| i.issue).collect()
493    }
494
495    #[test]
496    fn a_dependency_precedes_its_dependent() {
497        let out = numbers(vec![item(1, "s", &[2]), item(2, "l", &[])]);
498        let (a, b) = (
499            out.iter().position(|n| *n == 2).unwrap(),
500            out.iter().position(|n| *n == 1).unwrap(),
501        );
502        assert!(a < b, "{out:?}");
503    }
504
505    #[test]
506    fn cheapest_first_without_dependencies() {
507        assert_eq!(
508            vec![2, 3, 1],
509            numbers(vec![
510                item(1, "l", &[]),
511                item(2, "s", &[]),
512                item(3, "m", &[])
513            ])
514        );
515    }
516
517    #[test]
518    fn a_cycle_does_not_hang() {
519        assert_eq!(
520            2,
521            numbers(vec![item(1, "s", &[2]), item(2, "s", &[1])]).len()
522        );
523    }
524
525    #[test]
526    fn an_unknown_dependency_is_ignored() {
527        assert_eq!(vec![1], numbers(vec![item(1, "s", &[99])]));
528    }
529
530    #[test]
531    fn every_item_appears_exactly_once() {
532        let items: Vec<PlanItem> = (1..=5).map(|n| item(n, "m", &[])).collect();
533        let out = numbers(items);
534        assert_eq!(vec![1, 2, 3, 4, 5], out);
535    }
536
537    #[test]
538    fn a_dependency_chain_is_ordered_end_to_end() {
539        let items: Vec<PlanItem> = (1..=5)
540            .map(|n| {
541                let deps: Vec<i64> = if n > 1 { vec![n - 1] } else { vec![] };
542                PlanItem {
543                    depends_on: deps,
544                    ..item(n, "m", &[])
545                }
546            })
547            .collect();
548        assert_eq!(vec![1, 2, 3, 4, 5], numbers(items));
549    }
550
551    // -- reconciliation --------------------------------------------------
552
553    fn issue(n: i64) -> Issue {
554        Issue {
555            number: n,
556            title: format!("issue {n}"),
557            body: Some("body".into()),
558            state: "OPEN".into(),
559            url: String::new(),
560            labels: vec![],
561        }
562    }
563
564    fn verdict(n: i64, worth: bool, complexity: &str, risk: &str, deps: &[i64]) -> TriageVerdict {
565        TriageVerdict {
566            issue: n,
567            worth_doing: worth,
568            tracker: false,
569            reason: format!("because {n}"),
570            complexity: Complexity::parse_lenient(complexity).unwrap(),
571            depends_on: deps.to_vec(),
572            risk: Risk::parse_lenient(risk).unwrap(),
573        }
574    }
575
576    fn pair(
577        a: Vec<TriageVerdict>,
578        b: Vec<TriageVerdict>,
579    ) -> Vec<(String, BTreeMap<i64, TriageVerdict>)> {
580        vec![
581            (
582                "claude".to_string(),
583                a.into_iter().map(|v| (v.issue, v)).collect(),
584            ),
585            (
586                "codex".to_string(),
587                b.into_iter().map(|v| (v.issue, v)).collect(),
588            ),
589        ]
590    }
591
592    #[test]
593    fn both_agreeing_to_do_schedules_it() {
594        let plan = reconcile(
595            &[issue(1)],
596            &pair(
597                vec![verdict(1, true, "s", "low", &[])],
598                vec![verdict(1, true, "s", "low", &[])],
599            ),
600        );
601        assert_eq!(1, plan.order.len());
602        assert!(plan.skipped.is_empty() && plan.contested.is_empty());
603    }
604
605    #[test]
606    fn both_agreeing_to_skip_records_both_reasons() {
607        let plan = reconcile(
608            &[issue(1)],
609            &pair(
610                vec![verdict(1, false, "s", "low", &[])],
611                vec![verdict(1, false, "s", "low", &[])],
612            ),
613        );
614        assert_eq!(1, plan.skipped.len());
615        assert_eq!(2, plan.skipped[0].reasons.len());
616        assert!(plan.skipped[0].reasons.contains_key("claude"));
617        assert!(plan.skipped[0].reasons.contains_key("codex"));
618    }
619
620    /// One agent never overrules the other. A split goes to a person.
621    #[test]
622    fn a_disagreement_is_contested_not_averaged() {
623        let plan = reconcile(
624            &[issue(1)],
625            &pair(
626                vec![verdict(1, true, "s", "low", &[])],
627                vec![verdict(1, false, "s", "low", &[])],
628            ),
629        );
630        assert!(plan.order.is_empty() && plan.skipped.is_empty());
631        assert_eq!(1, plan.contested.len());
632        assert_eq!(
633            Some(&"do".to_string()),
634            plan.contested[0].positions.get("claude")
635        );
636        assert_eq!(
637            Some(&"skip".to_string()),
638            plan.contested[0].positions.get("codex")
639        );
640    }
641
642    #[test]
643    fn a_missing_verdict_is_contested_and_says_who_was_silent() {
644        let plan = reconcile(
645            &[issue(1)],
646            &pair(vec![verdict(1, true, "s", "low", &[])], vec![]),
647        );
648        assert_eq!(1, plan.contested.len());
649        assert!(plan.contested[0].note.as_deref().unwrap().contains("codex"));
650    }
651
652    #[test]
653    fn the_pessimistic_estimate_wins() {
654        let plan = reconcile(
655            &[issue(1)],
656            &pair(
657                vec![verdict(1, true, "s", "low", &[])],
658                vec![verdict(1, true, "l", "high", &[])],
659            ),
660        );
661        assert_eq!(Complexity::L, plan.order[0].complexity);
662        assert_eq!(Risk::High, plan.order[0].risk);
663    }
664
665    #[test]
666    fn dependencies_from_both_agents_are_unioned() {
667        let plan = reconcile(
668            &[issue(1)],
669            &pair(
670                vec![verdict(1, true, "s", "low", &[2, 3])],
671                vec![verdict(1, true, "s", "low", &[3, 4])],
672            ),
673        );
674        assert_eq!(vec![2, 3, 4], plan.order[0].depends_on);
675    }
676
677    // -- trackers ---------------------------------------------------------
678
679    /// The failure this exists to stop. Both agents correctly declined an
680    /// umbrella whose three subtasks were filed separately and still open, and
681    /// spar closed it as "not planned". Declining to open a pull request for a
682    /// tracker is right; closing it does not follow from that.
683    #[test]
684    fn an_umbrella_both_agents_declined_is_skipped_but_held_open() {
685        let plan = reconcile(
686            &[issue(1)],
687            &pair(vec![tracker_verdict(1)], vec![tracker_verdict(1)]),
688        );
689        assert!(plan.order.is_empty());
690        assert_eq!(1, plan.skipped.len());
691        assert!(plan.skipped[0].tracker, "a tracker must not be closeable");
692    }
693
694    /// Closing already needs both agents to decline, on the principle that one
695    /// agent's opinion is not enough to close somebody's report. One agent
696    /// saying the issue is not finished is that same principle from the other
697    /// side, so either is enough to withhold the close.
698    #[test]
699    fn one_agent_calling_it_a_tracker_is_enough_to_hold_it_open() {
700        let plan = reconcile(
701            &[issue(1)],
702            &pair(
703                vec![tracker_verdict(1)],
704                vec![verdict(1, false, "s", "low", &[])],
705            ),
706        );
707        assert!(plan.skipped[0].tracker);
708    }
709
710    /// An ordinary decline stays closeable, which is the behaviour worth
711    /// keeping: an issue filed twice is finished.
712    #[test]
713    fn an_ordinary_decline_is_not_held_open() {
714        let plan = reconcile(
715            &[issue(1)],
716            &pair(
717                vec![verdict(1, false, "s", "low", &[])],
718                vec![verdict(1, false, "s", "low", &[])],
719            ),
720        );
721        assert!(!plan.skipped[0].tracker);
722    }
723
724    fn tracker_verdict(n: i64) -> TriageVerdict {
725        let mut v = verdict(n, false, "m", "low", &[]);
726        v.tracker = true;
727        v
728    }
729}