Skip to main content

magi/
report.rs

1//! Terminal rendering.
2//!
3//! A run produces a lot of state; the report exists so the operator can decide
4//! what to do next without opening `run.json`. It leads with the disagreement,
5//! because that is the part that carries information: three judges agreeing
6//! tells you nothing the winner's diff does not.
7//!
8//! Colour is a six-line local implementation rather than a crate. The
9//! alternatives all decide *for* you whether the stream supports colour, which
10//! makes the output untestable — `assert!(text.contains("winner  A"))` fails on
11//! an escape sequence the test never asked for.
12use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::{MergeMode, MergeStyle};
16use crate::run::{
17    CommandOutcome, ContinuationOutcome, E2eStatus, GateStatus, JobStatus, Liveness,
18    OperatorFixOutcome, RunState, RunStatus, tail,
19};
20use crate::stats::{RepoStats, Stats};
21use crate::verdict::ReviewVote;
22
23static COLOR: AtomicBool = AtomicBool::new(true);
24
25/// Turn colour on or off for every subsequent render.
26pub fn set_color(on: bool) {
27    COLOR.store(on, Ordering::Relaxed);
28}
29
30fn paint(text: &str, code: &str) -> String {
31    if COLOR.load(Ordering::Relaxed) {
32        format!("\x1b[{code}m{text}\x1b[0m")
33    } else {
34        text.to_owned()
35    }
36}
37
38fn bold(t: &str) -> String {
39    paint(t, "1")
40}
41fn dim(t: &str) -> String {
42    paint(t, "2")
43}
44fn red(t: &str) -> String {
45    paint(t, "31")
46}
47fn green(t: &str) -> String {
48    paint(t, "32")
49}
50fn yellow(t: &str) -> String {
51    paint(t, "33")
52}
53fn cyan(t: &str) -> String {
54    paint(t, "36")
55}
56
57/// Colour for a status word.
58///
59/// `Stalled` is deliberately not green: a run whose judges were taken out by a
60/// rate limit must not look like a healthy `Ready` in a one-line listing.
61///
62/// A `Ready` reached via `[merge] mode = "none"` is a second case that must
63/// not look like a plain `Ready`: that run is done for good, never picked up
64/// by the PR-polling merge watcher or anything else, while an ordinary
65/// `Ready` (a PR closed without merging, an already-concluded re-entry) may
66/// still be a live landing candidate. See [`RunState::unmerged_by_design`].
67fn status_word(state: &RunState) -> String {
68    if state.unmerged_by_design() {
69        return cyan("unmerged (no-op by design)");
70    }
71    let text = state.status.display_label();
72    if state.needs_attention() {
73        return bold(&yellow(&format!("{text} - release needs a human")));
74    }
75    match state.status {
76        RunStatus::Merged => bold(&green(text)),
77        RunStatus::Ready => green(text),
78        RunStatus::Stalled => bold(&yellow(text)),
79        RunStatus::Blocked => yellow(text),
80        RunStatus::Failed => red(text),
81        // Not `Failed`'s red: every candidate agreed, with evidence, that
82        // nothing belongs in this worktree — the opposite of a run that
83        // could not do the work. See `RunStatus::VerifiedNoop`'s own doc.
84        RunStatus::VerifiedNoop => cyan(text),
85        // Muted, not `Blocked`'s yellow: nothing here needs a human's
86        // attention any more, a later attempt already settled the task.
87        RunStatus::Superseded => dim(text),
88        // Neither green nor red: this run landed nothing, and nothing is
89        // wrong - the change reached the base by another route.
90        RunStatus::AlreadyInBase => dim(text),
91        _ => cyan(text),
92    }
93}
94
95/// Colour for a reviewer vote — the same scale a finding's severity gets:
96/// green for no reservations, yellow for proceed-but-look-at-this, red for a
97/// vote that says stop.
98fn vote_tag(vote: ReviewVote) -> String {
99    let text = vote.label();
100    match vote {
101        ReviewVote::Approve => green(text),
102        ReviewVote::ApproveWithFindings => yellow(text),
103        ReviewVote::Reject => red(text),
104    }
105}
106
107/// One-line summary, for `magi list`.
108pub fn line(state: &RunState) -> String {
109    line_with_liveness(state, Liveness::Unknown)
110}
111
112/// [`line`] with the independent process-liveness observation available to
113/// `magi list`.
114pub fn line_with_liveness(state: &RunState, live: Liveness) -> String {
115    let winner = state
116        .tally
117        .as_ref()
118        .map_or("-".to_owned(), |t| t.winner.to_string());
119    let agent = state.winner().map_or("-", |c| c.agent.as_str());
120    // A below-quorum verdict carries an explicit stamp so a row in a listing
121    // reads "stalled" and "2/3 judges" without opening the report.
122    let quorum = match state.tally.as_ref() {
123        Some(t) if !t.met_quorum => format!(
124            "  {}",
125            bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
126        ),
127        Some(t) if t.present > 0 && t.present < t.judges => format!(
128            "  {}",
129            yellow(&format!("judges {}/{}", t.present, t.judges))
130        ),
131        _ => String::new(),
132    };
133    let stale = if !state.status.done() && live == Liveness::Dead {
134        format!(
135            "  {}",
136            bold(&yellow("STALE — driver exited; resume required"))
137        )
138    } else {
139        String::new()
140    };
141    // The full unknown wording is for `magi show`; a row only needs to say
142    // there is no record.
143    let origin = state
144        .origin
145        .as_ref()
146        .map_or("origin unknown".to_owned(), crate::run::Origin::label);
147    format!(
148        "{}  {:<20}  {:>2}c {:>2}j  win {} ({}){quorum}{stale}  {}  {}",
149        dim(&state.id),
150        status_word(state),
151        state.candidates.len(),
152        state.judgements.len(),
153        winner,
154        agent,
155        dim(&format!("[{origin}]")),
156        first_line(&state.instruction)
157    )
158}
159
160/// A plainly visible verdict for a non-terminal run whose recorded driver is
161/// gone. Kept separate from [`active_seats`] because a process can die at a
162/// node boundary with no active seat left in the saved state.
163pub fn liveness_notice(state: &RunState, live: Liveness) -> String {
164    if !state.status.done() && live == Liveness::Dead {
165        format!(
166            "{}\n\n",
167            yellow("STALE — the process driving this run exited; resume it to continue.")
168        )
169    } else {
170        String::new()
171    }
172}
173
174/// A plainly visible note for a run a later attempt at the same task already
175/// replaced — the terminal counterpart of the web UI's "Superseded by …"
176/// line. `later` is the short id of that later attempt, or `None` when this
177/// run is not superseded at all (the common case).
178///
179/// Kept as its own function, concatenated by the caller, rather than an
180/// argument threaded through [`run`] itself: `run` is called from `bump.rs`,
181/// `tui.rs` and every `magi show`/`magi list` path with no notion of "what
182/// replaced this", and adding a parameter there would mean guessing `None`
183/// at every one of those call sites instead of only at the one that knows.
184pub fn superseded_notice(later: Option<&str>) -> String {
185    match later {
186        Some(later) => format!(
187            "{}\n\n",
188            yellow(&format!(
189                "SUPERSEDED — a later attempt at this task ({later}) replaced it; this record \
190                 is kept but is not the one to act on."
191            ))
192        ),
193        None => String::new(),
194    }
195}
196
197fn first_line(text: &str) -> String {
198    let line = text.lines().next().unwrap_or_default();
199    if line.chars().count() > 68 {
200        format!("{}…", line.chars().take(67).collect::<String>())
201    } else {
202        line.to_owned()
203    }
204}
205
206fn short(commit: &str) -> String {
207    commit.chars().take(7).collect()
208}
209
210/// A short badge for a [`crate::run::ContinuationRecord`], distinguishing
211/// "the seat's own report needed to be resumed" from "there was nothing to
212/// address" — a fixer that has never needed this stays silent here, exactly
213/// as a record predating the feature (`continuation: None`) does too.
214fn continuation_note(c: &crate::run::ContinuationRecord) -> String {
215    match c.outcome {
216        ContinuationOutcome::NotNeeded => String::new(),
217        ContinuationOutcome::Resumed => format!(" [resumed x{}]", c.attempts),
218        ContinuationOutcome::Exhausted => format!(" [continuation exhausted x{}]", c.attempts),
219        ContinuationOutcome::QuotaLost => " [continuation: quota]".to_owned(),
220        ContinuationOutcome::NoSession => " [no session to resume]".to_owned(),
221    }
222}
223
224/// Commands seats' own CLIs reported running, across every node — see
225/// [`crate::run::JobRecord`]. Read-only: renders whatever `run.json` already
226/// holds, does not query anything live and does not spawn an agent.
227///
228/// A CLI this crate has no adapter for (every backend but Codex, as of this
229/// writing) never appears here at all — silence is "no evidence", not "no
230/// jobs ran", which the trailing coverage line exists to say once rather
231/// than per seat.
232///
233/// There is deliberately no "running" state anywhere in this. A command a
234/// CLI never reported finishing has no way to be told apart from one that
235/// never started at all: the CLI that would report it has, by construction,
236/// already stopped talking (killed by a timeout, or a park) by the time that
237/// question matters, so no event — real or guessed at — could ever answer
238/// it. Guessing at an unconfirmed event shape to manufacture a "running"
239/// entry is exactly the fabrication this feature must not do; recording that
240/// limit instead is what the task asks for here. `magi show`'s own
241/// seat-completion state ([`active_seats`]) still answers a related but
242/// different question honestly — "has this seat's own turn answered yet" —
243/// and stays the right place to look for that.
244fn jobs_section(state: &RunState) -> String {
245    let mut s = String::new();
246    if state.jobs.is_empty() {
247        // Not silent when it would matter: a run whose roster can actually
248        // report this (Codex, today) but has not yet says so explicitly, so
249        // "no adapter for this backend" and "nothing reported yet" are never
250        // the same blank space to a reader.
251        if state
252            .config
253            .agents
254            .iter()
255            .any(|a| a.kind == crate::config::AgentKind::Codex)
256        {
257            let _ = writeln!(
258                s,
259                "\n{}",
260                dim(
261                    "background jobs: no completed command evidence yet for this run (see \
262                     active seats above for what is still mid-turn)"
263                )
264            );
265        }
266        return s;
267    }
268    let _ = writeln!(
269        s,
270        "\n{}",
271        bold("background jobs (from each seat's own CLI)")
272    );
273    let mut by_seat: std::collections::BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> =
274        std::collections::BTreeMap::new();
275    for j in &state.jobs {
276        by_seat
277            .entry((j.node.as_str(), j.seat.as_str()))
278            .or_default()
279            .push(j);
280    }
281    for ((node, seat), records) in by_seat {
282        let _ = writeln!(s, "  {node}/{seat}");
283        for j in records {
284            let status = match j.status {
285                JobStatus::Completed => green("completed"),
286                JobStatus::Failed => red("failed"),
287                JobStatus::Unknown => yellow("unknown"),
288            };
289            let _ = writeln!(
290                s,
291                "    {} {}{}{}  checked {}",
292                dim(&j.id),
293                status,
294                j.exit_code
295                    .map_or(String::new(), |c| format!(" (exit {c})")),
296                j.round.map_or(String::new(), |r| format!("  round {r}")),
297                j.checked_at
298                    .to_zoned(jiff::tz::TimeZone::system())
299                    .strftime("%Y-%m-%d %H:%M:%S")
300            );
301            let desc = first_line(&j.description);
302            if !desc.trim().is_empty() {
303                let _ = writeln!(s, "      $ {desc}");
304            }
305            let summary = first_line(&j.result_summary);
306            if !summary.trim().is_empty() {
307                let _ = writeln!(s, "      {}", dim(&summary));
308            }
309        }
310    }
311    let _ = writeln!(
312        s,
313        "  {}",
314        dim(
315            "(adapter coverage: codex only today; other backends, and a command a CLI never \
316             reported finishing, leave no entry here — that is unknown, never \"nothing ran\")"
317        )
318    );
319    s
320}
321
322/// Full report for one run.
323pub fn run(state: &RunState) -> String {
324    let mut s = String::new();
325    let _ = writeln!(
326        s,
327        "{} {}  {}",
328        bold("magi run"),
329        bold(&state.id),
330        status_word(state)
331    );
332    let _ = writeln!(
333        s,
334        "  repo    {} ({} @ {})",
335        state.repo.display(),
336        state.base_branch,
337        short(&state.base_commit)
338    );
339    let _ = writeln!(s, "  created {}", state.created_local());
340    let _ = writeln!(s, "  task    {}", first_line(&state.instruction));
341    let _ = writeln!(
342        s,
343        "  origin  {}",
344        crate::run::origin_label(state.origin.as_ref())
345    );
346    let _ = writeln!(s, "  state   {}", state.dir().display());
347
348    let _ = writeln!(s, "\n{}", bold("candidates"));
349    for c in &state.candidates {
350        let flag = match (&c.failed, c.empty, &c.verified_noop) {
351            (Some(e), _, _) => red(&format!("failed: {e}")),
352            // Neither red (nothing failed) nor plain yellow "no change" (that
353            // reads as an unexplained loss): the candidate gave a reason a
354            // human still has to check, not a claim magi itself confirmed.
355            (None, true, Some(_)) => cyan("agent-verified no-op (unconfirmed)"),
356            (None, true, None) => yellow("no change"),
357            _ => format!("{} files, {} commits", c.files, c.commits),
358        };
359        let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
360            bold(&green("  <- winner"))
361        } else {
362            String::new()
363        };
364        let _ = writeln!(
365            s,
366            "  {}  {:<12} {:<30} {:>5}s{}",
367            bold(&c.label.to_string()),
368            c.agent,
369            flag,
370            c.duration_ms / 1000,
371            crown
372        );
373        if let Some(evidence) = &c.verified_noop {
374            let _ = writeln!(s, "      {}", dim(&first_line(evidence)));
375        } else if !c.summary.trim().is_empty() {
376            // The candidate's own account of what it did and why — the
377            // "## SUMMARY" `prompt::implement` asks for — was recorded on
378            // every run but never surfaced here, which left `magi show`
379            // silent about it even when the summary was the whole point (an
380            // implementer explaining *why* it wrote nothing, short of a
381            // verified no-op's own line above). One line, matching the
382            // house style other prose fields get in this report (see the
383            // review findings' `detail` below); the rest is in `run.json`.
384            let _ = writeln!(s, "      {}", dim(&first_line(&c.summary)));
385        }
386    }
387
388    if !state.judgements.is_empty() {
389        let _ = writeln!(s, "\n{}", bold("blind judging"));
390        for j in &state.judgements {
391            match &j.failed {
392                Some(e) => {
393                    let _ = writeln!(
394                        s,
395                        "  judge {}  {}",
396                        j.judge,
397                        red(&format!("no ranking: {e}"))
398                    );
399                }
400                None => {
401                    let _ = writeln!(
402                        s,
403                        "  judge {}  {:<12} {}  confidence {}",
404                        j.judge,
405                        j.agent,
406                        bold(&j.ranking.iter().collect::<String>()),
407                        j.confidence.map_or("-".to_owned(), |c| c.to_string())
408                    );
409                }
410            }
411        }
412    }
413
414    if let Some(t) = &state.tally {
415        if t.deliberated {
416            let _ = writeln!(s, "\n{}", bold("deliberation"));
417            for round in &state.deliberation {
418                for turn in &round.turns {
419                    let _ = writeln!(
420                        s,
421                        "  r{} judge {} -> {}",
422                        round.round,
423                        turn.judge,
424                        turn.tentative.map_or("-".to_owned(), |c| c.to_string())
425                    );
426                }
427            }
428        }
429
430        if !state.votes.is_empty() {
431            let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
432            for v in &state.votes {
433                let _ = writeln!(
434                    s,
435                    "  judge {}  {:<12} {}{}",
436                    v.judge,
437                    v.agent,
438                    bold(&v.vote.unwrap_or('?').to_string()),
439                    if v.changed {
440                        yellow("  (changed after deliberation)")
441                    } else {
442                        String::new()
443                    }
444                );
445            }
446        }
447
448        let _ = writeln!(s, "\n{}", bold("tally"));
449        // A tally with no panel (`uncontested`) must not fall through the
450        // judges/first-choice/after-votes lines below: they are written
451        // unconditionally and every one of them reads, in the words a panel
452        // that collapsed would also produce, as a run that lost its judges
453        // rather than one that never needed them.
454        match &t.uncontested {
455            Some(reason) => {
456                let _ = writeln!(
457                    s,
458                    "  judging       {}",
459                    cyan(&format!("not needed — {reason}"))
460                );
461            }
462            None => {
463                let _ = writeln!(
464                    s,
465                    "  judges        {} present{}",
466                    if t.met_quorum {
467                        green(&format!("{}/{}", t.present, t.judges))
468                    } else {
469                        red(&format!("{}/{}", t.present, t.judges))
470                    },
471                    if t.quorum > 0 {
472                        format!(" ({quorum} required)", quorum = t.quorum)
473                    } else {
474                        String::new()
475                    }
476                );
477                if !t.met_quorum {
478                    let _ = writeln!(
479                        s,
480                        "  {}",
481                        bold(&red("BELOW QUORUM — verdict is not trustworthy"))
482                    );
483                }
484                let _ = writeln!(
485                    s,
486                    "  first choice  {}",
487                    t.first_choice
488                        .iter()
489                        .map(|(k, v)| format!("{k}:{v}"))
490                        .collect::<Vec<_>>()
491                        .join("  ")
492                );
493                let _ = writeln!(
494                    s,
495                    "  initial       {}",
496                    match (t.rankings, t.unanimous_initial) {
497                        (0, _) => red("no usable ranking"),
498                        (1, _) => yellow("one usable ranking - not a consensus"),
499                        (_, true) => green("unanimous"),
500                        (_, false) => yellow("split"),
501                    }
502                );
503                let _ = writeln!(
504                    s,
505                    "  after votes   {}  ({} judge(s) moved)",
506                    if t.unanimous_final {
507                        green("unanimous")
508                    } else {
509                        yellow("still split")
510                    },
511                    t.changed_votes
512                );
513                if let Some(tb) = &t.tie_break {
514                    let _ = writeln!(s, "  tie break     {tb}");
515                }
516            }
517        }
518        if !state.quota.is_empty() {
519            let _ = writeln!(
520                s,
521                "  rate limited  {}",
522                state
523                    .quota
524                    .iter()
525                    .map(|q| q.seat.as_str())
526                    .collect::<Vec<_>>()
527                    .join(", ")
528            );
529        }
530        let _ = writeln!(s, "  winner        {}", bold(&green(&t.winner.to_string())));
531    }
532
533    if !state.handovers.is_empty() {
534        let _ = writeln!(s, "\n{}", bold("seats handed over"));
535        for h in &state.handovers {
536            let _ = writeln!(
537                s,
538                "  {} {}  {} -> {}  ({}: {})",
539                yellow("~"),
540                h.seat,
541                h.from,
542                h.to,
543                h.node,
544                h.reason
545            );
546        }
547    }
548
549    if !state.withheld.is_empty() {
550        let _ = writeln!(s, "\n{}", bold("withheld from commit"));
551        for w in &state.withheld {
552            let _ = writeln!(
553                s,
554                "  {} {}  ({} lockfile; the directory uses {})",
555                yellow("!"),
556                w.path,
557                w.manager,
558                w.kept_by
559            );
560        }
561    }
562
563    if let Some(r) = &state.recovery {
564        let _ = writeln!(s, "\n{}", bold("recovery"));
565        let _ = writeln!(s, "  {}", r.summary());
566        if r.criteria_inherited {
567            let _ = writeln!(s, "  the task's own completion criteria were kept");
568        }
569        match &r.repair {
570            crate::recovery::Repair::NotStarted => {}
571            crate::recovery::Repair::WaitingForRestore { .. } => {
572                let _ = writeln!(s, "  repair: waiting to restore and retry");
573            }
574            crate::recovery::Repair::Started { .. } => {
575                let _ = writeln!(s, "  repair: {}", yellow("unfinished"));
576            }
577            crate::recovery::Repair::Done {
578                committed, failed, ..
579            } => {
580                let _ = match failed {
581                    Some(why) => writeln!(s, "  repair: {} {why}", yellow("nothing kept:")),
582                    None if *committed => writeln!(s, "  repair: {}", green("committed")),
583                    None => writeln!(s, "  repair: no commit"),
584                };
585            }
586        }
587    }
588
589    if let Some(g) = &state.graft {
590        let _ = writeln!(s, "\n{}", bold("graft"));
591        let size = g.lines.map_or_else(
592            || "  changed lines unavailable".to_owned(),
593            |n| format!("  {n} changed lines"),
594        );
595        let _ = match &g.outcome {
596            crate::run::GraftOutcome::Applied => writeln!(s, "  {}{size}", green("applied")),
597            crate::run::GraftOutcome::WaitingForRestore => {
598                writeln!(s, "  {}{size}", yellow("waiting to restore and retry"))
599            }
600            crate::run::GraftOutcome::Running => writeln!(s, "  {}{size}", yellow("unfinished")),
601            crate::run::GraftOutcome::RolledBack(why) => {
602                writeln!(s, "  {} {why}{size}", yellow("rolled back:"))
603            }
604        };
605        if let Some(a) = &g.agent {
606            let chain: Vec<String> = state
607                .handovers
608                .iter()
609                .filter(|h| h.node == "graft")
610                .map(|h| format!("{} -> {}", h.from, h.to))
611                .collect();
612            let via = if chain.is_empty() {
613                String::new()
614            } else {
615                format!("  (handovers: {})", chain.join(", "))
616            };
617            let _ = writeln!(s, "  by {a}{via}");
618        }
619        if !g.requested.is_empty() {
620            let view = crate::report_view::build(state, Liveness::Unknown);
621            if let Some(crate::report_view::Section::Graft { graft, .. }) = view
622                .sections
623                .iter()
624                .find(|x| matches!(x, crate::report_view::Section::Graft { .. }))
625            {
626                let _ = writeln!(
627                    s,
628                    "  requested {}  confirmed adopted {}  rejected {}  unconfirmed {}",
629                    graft.requested.len(),
630                    graft.adopted_count,
631                    graft.rejected_count,
632                    graft.unconfirmed_count
633                );
634            }
635        }
636        if let Some(n) = g
637            .adoption
638            .as_ref()
639            .and_then(|a| a.notes.lines().find(|l| !l.trim().is_empty()))
640        {
641            let _ = writeln!(s, "  fixer notes: {}", n.trim());
642        }
643    }
644
645    if !state.reviews.is_empty() {
646        let _ = writeln!(s, "\n{}", bold("review + verification"));
647        for r in &state.reviews {
648            let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
649            // A build/link failure is not a verdict on the patch (a shared
650            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
651            // must not read the same as a real test failure. And a deferred
652            // round is not a passed one: `e2e.is_empty()` alone cannot tell
653            // "not configured" from "skipped on purpose" apart, which is
654            // exactly why `e2e_status` exists rather than reading `e2e`
655            // directly here.
656            let e2e = match r.e2e_status() {
657                E2eStatus::NotConfigured => dim("no e2e"),
658                E2eStatus::Deferred => yellow(&format!(
659                    "e2e deferred{}",
660                    r.e2e_defer_reason
661                        .as_deref()
662                        .map(|why| format!(" ({why})"))
663                        .unwrap_or_default()
664                )),
665                E2eStatus::Passed => green("e2e green"),
666                E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
667                    yellow("e2e could not run (build/link failure)")
668                }
669                E2eStatus::Failed => red("e2e RED"),
670                // Magi's own admission it could not get a command to run —
671                // never the same yellow/red a real attempt earns, since
672                // nothing here is evidence about the patch (see
673                // `E2eStatus::ResourceBlocked`'s own doc).
674                E2eStatus::ResourceBlocked => yellow("e2e could not run (resource unavailable)"),
675            };
676            let e2e = if r.verify_retried {
677                format!("{e2e}, retried once")
678            } else {
679                e2e
680            };
681            // Three distinct facts, not two: a round can be *open* (blocking
682            // findings still standing), *incomplete* (a seat never answered,
683            // so what the round says is missing input) or genuinely clean.
684            let status = if r.incomplete() {
685                yellow("incomplete")
686            } else if r.clean {
687                green("clean")
688            } else {
689                yellow("open")
690            };
691            // A missing seat must stay visible even when `warn` policy let
692            // the round gate as clean: the reader should never have to take
693            // "clean" on faith when the panel wasn't full.
694            let panel = if r.incomplete() {
695                let missing: Vec<String> = r
696                    .reviews
697                    .iter()
698                    .filter_map(|x| {
699                        x.failed
700                            .as_ref()
701                            .map(|why| format!("review-{}: {why}", x.reviewer))
702                    })
703                    .collect();
704                format!(
705                    "  {}/{} reviewers answered ({})",
706                    r.answered,
707                    r.expected,
708                    missing.join(", ")
709                )
710            } else {
711                String::new()
712            };
713            // The verdict is the one thing this loop cannot derive from
714            // `blocking`/`e2e` alone: three seats can agree there is nothing
715            // blocking and still split on whether the patch is fine to
716            // proceed as-is, which is exactly the disagreement a vote exists
717            // to surface.
718            let verdict = r.verdict.map_or(String::new(), |v| {
719                format!(
720                    ", verdict {}{}",
721                    vote_tag(v),
722                    if r.vote_split { " (panel split)" } else { "" }
723                )
724            });
725            let _ = writeln!(
726                s,
727                "  round {}  {} @ {}{}{panel}  {raised} finding(s), {} blocking, {e2e}{verdict}{}",
728                r.round,
729                status,
730                short(&r.head),
731                r.verified_head.as_ref().map_or(String::new(), |head| {
732                    format!(
733                        " (verified @ {}{})",
734                        short(head),
735                        r.verified_at.map_or(String::new(), |t| format!(
736                            " on {}",
737                            t.to_zoned(jiff::tz::TimeZone::system())
738                                .strftime("%Y-%m-%d %H:%M:%S")
739                        ))
740                    )
741                }),
742                r.blocking,
743                r.fix.as_ref().map_or(String::new(), |f| {
744                    let tree = if r.progressed {
745                        green("changed")
746                    } else {
747                        yellow("unchanged")
748                    };
749                    let cont = f
750                        .continuation
751                        .as_ref()
752                        .map_or(String::new(), continuation_note);
753                    match &f.failed {
754                        // Never the same shape as "N addressed / M rejected": the
755                        // fixer's diff may well have landed (see the `fix` node's
756                        // own event), but whether it addressed anything is
757                        // unknown, not zero.
758                        Some(reason) => format!(
759                            "  fix: {}, tree {tree}{}{cont}",
760                            yellow(&format!("adoption report lost ({reason})")),
761                            if f.committed {
762                                String::new()
763                            } else {
764                                red(" (NO COMMIT)")
765                            }
766                        ),
767                        None => format!(
768                            "  fix: {} addressed / {} rejected, tree {tree}{}{cont}",
769                            f.addressed.len(),
770                            f.rejected.len(),
771                            if f.committed {
772                                String::new()
773                            } else {
774                                red(" (NO COMMIT)")
775                            }
776                        ),
777                    }
778                })
779            );
780            // The aggregate label above (`e2e`) says the round's overall
781            // verdict; it never named the commands themselves, so a round
782            // with more than one `verify.e2e` command left no way to tell
783            // which one actually failed or was blocked without opening
784            // `run.json` by hand — the same gap `gate`'s own per-command
785            // listing above already closes for the final gate.
786            for o in &r.e2e {
787                let label = if o.resource_blocked {
788                    yellow("blocked")
789                } else if o.ok() {
790                    green("pass")
791                } else {
792                    red("FAIL")
793                };
794                let _ = writeln!(s, "    {label}  {}", o.command);
795                if !o.ok() {
796                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
797                }
798            }
799            for rec in &r.reviews {
800                if let Some(vote) = rec.vote {
801                    let _ = writeln!(s, "      review-{} vote {}", rec.reviewer, vote_tag(vote));
802                }
803                for f in &rec.findings {
804                    let adopted = r
805                        .fix
806                        .as_ref()
807                        .is_some_and(|fix| fix.addressed.contains(&f.id));
808                    let _ = writeln!(
809                        s,
810                        "      {} [{:?}] {}{}",
811                        dim(&f.id),
812                        f.severity,
813                        f.title,
814                        if adopted {
815                            green("  fixed")
816                        } else {
817                            String::new()
818                        }
819                    );
820                }
821            }
822            if let Some(fix) = &r.fix {
823                for rej in &fix.rejected {
824                    let _ = writeln!(
825                        s,
826                        "      {} {}: {}",
827                        dim(&rej.id),
828                        yellow("declined"),
829                        rej.why
830                    );
831                }
832            }
833            // Reconsideration only ever has entries when the round's initial
834            // votes split — an empty list here means the panel agreed the
835            // first time, same as an empty `deliberation` for judges.
836            if !r.reconsideration.is_empty() {
837                let _ = writeln!(s, "      {}", dim("reconsideration:"));
838                for rv in &r.reconsideration {
839                    match rv.vote {
840                        Some(v) => {
841                            let _ = writeln!(
842                                s,
843                                "        review-{} -> {}  {}",
844                                rv.reviewer,
845                                vote_tag(v),
846                                rv.reason
847                            );
848                        }
849                        None => {
850                            let _ = writeln!(
851                                s,
852                                "        review-{} -> {}",
853                                rv.reviewer,
854                                red(&format!(
855                                    "no revote ({})",
856                                    rv.failed.as_deref().unwrap_or("unknown")
857                                ))
858                            );
859                        }
860                    }
861                }
862            }
863        }
864        if state.handed_off_with_open_findings() {
865            let _ = writeln!(
866                s,
867                "\n  {}",
868                yellow(&format!(
869                    "handed off with {} finding(s) still open — gate and e2e were green; \
870                     see above for what a person should still look at",
871                    state.open_findings().len()
872                ))
873            );
874        }
875    }
876
877    if !state.merge_repairs.is_empty() {
878        let _ = writeln!(s, "\n{}", bold("merge-gate repairs"));
879        for r in &state.merge_repairs {
880            let _ = writeln!(
881                s,
882                "  question {}  head {}  {:?}\n  {}",
883                r.question,
884                crate::run::short_of(&r.head),
885                r.phase,
886                r.pr
887            );
888            if let Some(why) = &r.failure {
889                let _ = writeln!(s, "  held: {why}");
890            }
891            if let Some(round) = r.reviews.last() {
892                for f in round.reviews.iter().flat_map(|rec| &rec.findings) {
893                    let _ = writeln!(s, "  original [{}] {:?}: {}", f.id, f.severity, f.title);
894                }
895                for (seat, _, vote) in round.final_votes() {
896                    let _ = writeln!(s, "  original reviewer #{seat}: {:?}", vote);
897                }
898            }
899        }
900        s.push_str(
901            "  Repair never approves a merge; updated results require a fresh owner decision.\n",
902        );
903    }
904
905    if !state.operator_fixes.is_empty() {
906        let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
907        for (i, req) in state.operator_fixes.iter().enumerate() {
908            let _ = writeln!(
909                s,
910                "  [{}] {} finding(s) at {}{}",
911                i + 1,
912                req.findings.len(),
913                req.requested_at
914                    .to_zoned(jiff::tz::TimeZone::system())
915                    .strftime("%Y-%m-%d %H:%M:%S"),
916                if req.stale {
917                    yellow("  stale head, --allow-stale used")
918                } else {
919                    String::new()
920                }
921            );
922            let _ = writeln!(s, "      reason: {}", req.reason);
923            for f in &req.findings {
924                let outcome = match &f.outcome {
925                    OperatorFixOutcome::Pending => yellow("pending"),
926                    OperatorFixOutcome::Addressed => green("addressed"),
927                    OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
928                    OperatorFixOutcome::Unreported => {
929                        red("unreported — no adoption report came back")
930                    }
931                };
932                let _ = writeln!(
933                    s,
934                    "      {} [{:?}] {}  {outcome}",
935                    dim(&f.id),
936                    f.severity,
937                    f.title
938                );
939            }
940            match &req.follow_up_review_run {
941                Some(id) => {
942                    let _ = writeln!(s, "      re-verified by run {id}");
943                }
944                None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
945                    let _ = writeln!(
946                        s,
947                        "      {}",
948                        red("committed, but the follow-up review could not be opened")
949                    );
950                }
951                None => {
952                    let _ = writeln!(s, "      no change committed; nothing to re-verify");
953                }
954            }
955        }
956    }
957
958    if let Some(bs) = &state.base_sync {
959        let _ = writeln!(s, "\n{}", bold("base sync"));
960        let status = if let Some(a) = &bs.already_in {
961            dim(&format!(
962                "already in {} as {} ({} match) - nothing left to land",
963                state.base_branch,
964                a.names(),
965                a.proof.as_str()
966            ))
967        } else if let Some(c) = &bs.conflict {
968            red(&format!("conflict: {}", first_line(c)))
969        } else if bs.behind == 0 {
970            green("in sync")
971        } else {
972            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
973        };
974        let _ = writeln!(
975            s,
976            "  {} @ {}  {status}{}",
977            state.base_branch,
978            short(&bs.tip),
979            if bs.attempts > 0 {
980                format!("  ({} rebase attempt(s))", bs.attempts)
981            } else {
982                String::new()
983            }
984        );
985    }
986
987    if !state.pre_gate.is_empty() {
988        let _ = writeln!(s, "\n{}", bold("pre_gate"));
989        for o in &state.pre_gate {
990            let _ = writeln!(
991                s,
992                "  {}  {}",
993                if o.ok() {
994                    green("pass")
995                } else {
996                    yellow("warn")
997                },
998                o.command
999            );
1000            if !o.ok() {
1001                let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
1002            }
1003        }
1004        if let Some(c) = &state.pre_gate_commit {
1005            let _ = writeln!(s, "  committed mechanical fixes @ {}", short(c));
1006        }
1007    }
1008
1009    // `state.gate.is_empty()` alone cannot tell "never ran" apart from "ran
1010    // with nothing configured" — see `RunState::gate_status`'s own doc — so
1011    // this reads the accessor rather than the raw list.
1012    match state.gate_status() {
1013        GateStatus::NotRun => {}
1014        GateStatus::NotConfigured => {
1015            let _ = writeln!(s, "\n{}", bold("gate"));
1016            let _ = writeln!(s, "  {}  no gate commands configured", dim("not tested"));
1017        }
1018        GateStatus::Passed | GateStatus::Failed => {
1019            let _ = writeln!(s, "\n{}", bold("gate"));
1020            for o in &state.gate {
1021                let _ = writeln!(
1022                    s,
1023                    "  {}  {}",
1024                    if o.ok() { green("pass") } else { red("FAIL") },
1025                    o.command
1026                );
1027                if !o.ok() {
1028                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
1029                }
1030            }
1031        }
1032    }
1033
1034    if let Some(m) = &state.merge {
1035        let _ = writeln!(s, "\n{}", bold("merge"));
1036        if m.mode == MergeMode::None {
1037            // `ok: true` here means "magi did nothing, as configured", not
1038            // "landed" — a green `ok` next to a shell command reads as done,
1039            // and the branch is still sitting unmerged.
1040            let _ = writeln!(
1041                s,
1042                "  mode None  {}",
1043                cyan("not landed — nothing to do by design")
1044            );
1045            if let Some(w) = state.winner() {
1046                let _ = writeln!(
1047                    s,
1048                    "  branch {} still exists, unmerged into {}",
1049                    w.branch, state.base_branch
1050                );
1051            }
1052            // The squash caveat only applies to that one style: `--no-ff` and
1053            // `--ff-only` never inherit a candidate's placeholder subject,
1054            // since neither ever discards the pull request body `message`
1055            // that `manual_merge_command` (graph.rs) already puts on the
1056            // squash commit's `-m`.
1057            let _ = writeln!(
1058                s,
1059                "  rebase onto {} before merging by hand{}",
1060                state.base_branch,
1061                if state.config.merge.style == MergeStyle::Squash {
1062                    ", and pass an explicit commit message — a squash merge \
1063                     otherwise inherits the candidate's placeholder subject"
1064                } else {
1065                    ""
1066                }
1067            );
1068            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
1069        } else {
1070            let _ = writeln!(
1071                s,
1072                "  mode {:?}  {}\n  {}",
1073                m.mode,
1074                if m.empty {
1075                    yellow("empty candidate — nothing to land")
1076                } else if m.ok && state.status == RunStatus::Merged {
1077                    green("merged")
1078                } else if m.ok && m.mode == MergeMode::Pr {
1079                    yellow("published; not merged")
1080                } else {
1081                    yellow("not merged")
1082                },
1083                m.detail.lines().next().unwrap_or("")
1084            );
1085        }
1086        if let Some(pr) = &state.pr
1087            && !pr.red_at_merge.is_empty()
1088        {
1089            let _ = writeln!(
1090                s,
1091                "  {} {}",
1092                yellow("merged with red checks:"),
1093                pr.red_at_merge.join(", ")
1094            );
1095        }
1096    }
1097
1098    if !state.followups.is_empty() {
1099        let _ = writeln!(s, "\n{}", bold("follow-ups"));
1100        for f in &state.followups {
1101            let _ = writeln!(
1102                s,
1103                "  {} {}",
1104                crate::queue::short(&f.task),
1105                f.findings.join(", ")
1106            );
1107        }
1108    }
1109
1110    if !state.bump_decision_attempts.is_empty() {
1111        let _ = writeln!(s, "\n{}", bold("release decision attempts"));
1112        for (n, attempt) in state.bump_decision_attempts.iter().enumerate() {
1113            let _ = writeln!(s, "  {}. {}: {}", n + 1, attempt.agent, attempt.outcome);
1114        }
1115    }
1116    if let Some(b) = &state.release_bump {
1117        let _ = writeln!(s, "\n{}", bold("release bump"));
1118        if let Some(why) = &b.skipped {
1119            let _ = writeln!(
1120                s,
1121                "  {} {why}",
1122                bold(&yellow("SKIPPED (no release was made):"))
1123            );
1124        }
1125        if let Some(v) = &b.version {
1126            let _ = writeln!(s, "  version v{v}");
1127        }
1128        if let Some(url) = &b.pr_url {
1129            let _ = writeln!(s, "  pr {url}");
1130        }
1131        if b.skipped.is_none() || b.pr_url.is_some() {
1132            let _ = writeln!(
1133                s,
1134                "  automerge {}",
1135                if b.automerge_enabled {
1136                    green("enabled")
1137                } else if b.merged_directly {
1138                    green("not needed (CI was already green; merged directly)")
1139                } else if b.local {
1140                    dim(
1141                        "not used (release.mode = local: merged on your approval, released by magi serve)",
1142                    )
1143                } else if b.problem.is_some() {
1144                    bold(&red("FAILED"))
1145                } else {
1146                    dim("not enabled")
1147                }
1148            );
1149        }
1150        if let Some(p) = &b.problem {
1151            let _ = writeln!(s, "  {}", p.lines().next().unwrap_or(""));
1152        }
1153        if let Some(j) = &b.release {
1154            let state = if j.finished {
1155                "finished"
1156            } else {
1157                j.failed.as_deref().unwrap_or("unfinished")
1158            };
1159            let _ = writeln!(s, "  release {} at {}: {state}", j.tag(), j.commit);
1160            for l in &j.log {
1161                let code = l.code.map_or("none".to_owned(), |c| c.to_string());
1162                let _ = write!(s, "    {} (exit {code})", l.name);
1163                if let Some(o) = &l.output {
1164                    let _ = write!(s, " output {o}");
1165                }
1166                let _ = writeln!(s);
1167            }
1168        }
1169        if let Some(a) = &b.action_required {
1170            let _ = writeln!(s, "  {} {a}", bold(&yellow("action required:")));
1171        }
1172    }
1173
1174    if !state.leaks.is_empty() {
1175        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
1176        for l in &state.leaks {
1177            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
1178        }
1179    }
1180
1181    if let Some(w) = state.winner()
1182        && !w.folded
1183    {
1184        let _ = writeln!(
1185            s,
1186            "\n{} {}\n  branch {}",
1187            bold("winner worktree"),
1188            w.worktree.display(),
1189            w.branch
1190        );
1191    }
1192    s.push_str(&jobs_section(state));
1193    s
1194}
1195
1196/// The seats currently mid-answer, for `magi show` and the raw report route.
1197///
1198/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
1199/// run` / `magi review`'s own `execute()`, and by then this process has
1200/// nothing left in flight to report; the TUI does not track daemon liveness
1201/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
1202/// the web UI's raw-report route — needs this, and both already know how to
1203/// ask whether a daemon is currently driving it.
1204///
1205/// `live` is [`RunState::liveness`]: whether a daemon's heartbeat currently
1206/// names this run, or — absent that claim — whether `driver_pid` still
1207/// answers alive. An [`ActiveSeat`](crate::run::ActiveSeat) left behind by a
1208/// killed process is not lied about as running just because nobody has
1209/// cleared it from disk yet — see that type's own docs for why an entry
1210/// alone is not proof of anything.
1211pub fn active_seats(state: &RunState, live: Liveness) -> String {
1212    let mut s = String::new();
1213    if let Some(block) = &state.resource_block {
1214        let _ = writeln!(
1215            s,
1216            "\n{}\n  {}. Resume to retry the affected operation.",
1217            bold("waiting on a resource"),
1218            block.description()
1219        );
1220    }
1221    if state.active.is_empty() {
1222        return s;
1223    }
1224    let _ = writeln!(s, "\n{}", bold("running now"));
1225    let now = jiff::Timestamp::now();
1226    match live {
1227        Liveness::Live => {}
1228        Liveness::Dead => {
1229            let _ = writeln!(
1230                s,
1231                "  {}",
1232                yellow(
1233                    "no live daemon claims this run right now — likely left behind by a killed process"
1234                )
1235            );
1236        }
1237        Liveness::Unknown => {
1238            // `active_all_overrun` is never treated as proof of death here —
1239            // only ever mentioned alongside "could not confirm", since a
1240            // seat legitimately overrunning its budget while the process
1241            // driving it tears the attempt down looks identical on disk (see
1242            // `RunState::active_all_overrun`'s own doc).
1243            let overrun = if state.active_all_overrun(now) {
1244                " — every active seat has already run past its own timeout budget"
1245            } else {
1246                ""
1247            };
1248            let _ = writeln!(
1249                s,
1250                "  {}",
1251                yellow(&format!(
1252                    "whether a process is still driving this run could not be confirmed{overrun}"
1253                ))
1254            );
1255        }
1256    }
1257    for (seat, a) in state.seats_active() {
1258        let retry = if a.attempt > 0 {
1259            format!(" retry {}", a.attempt)
1260        } else {
1261            String::new()
1262        };
1263        // Before the CLI is spawned there is no agent budget to count down.
1264        if let Some(w) = state.resource_wait.as_ref().filter(|w| w.node == a.node) {
1265            let _ = writeln!(
1266                s,
1267                "  {:<12} {:<12}{retry}  waiting for the {} ({}), {}s of {}s; agent not started yet",
1268                seat,
1269                a.node,
1270                w.kind,
1271                w.holder,
1272                (now.as_second() - w.since.as_second()).max(0),
1273                w.limit_secs
1274            );
1275            continue;
1276        }
1277        let _ = writeln!(
1278            s,
1279            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
1280            seat,
1281            a.node,
1282            a.elapsed_secs(now),
1283            a.remaining_secs(now),
1284            a.timeout_secs
1285        );
1286    }
1287    for (task, a) in state.tasks_active() {
1288        let retry = if a.attempt > 0 {
1289            format!(" retry {}", a.attempt)
1290        } else {
1291            String::new()
1292        };
1293        let progress = match (a.index, a.total) {
1294            (Some(i), Some(t)) => format!(" ({i}/{t})"),
1295            _ => String::new(),
1296        };
1297        let _ = writeln!(
1298            s,
1299            "  {:<12} {:<12}{retry}{progress}  {}s elapsed, {}s left of {}s",
1300            task,
1301            a.node,
1302            a.elapsed_secs(now),
1303            a.remaining_secs(now),
1304            a.timeout_secs
1305        );
1306        if let Some(command) = &a.command {
1307            let _ = writeln!(s, "               {command}");
1308        }
1309    }
1310    s
1311}
1312
1313/// Render a whole number of seconds as `1h02m`, `3m05s` or `42s`.
1314fn fmt_secs(secs: i64) -> String {
1315    let secs = secs.max(0);
1316    let h = secs / 3600;
1317    let m = (secs % 3600) / 60;
1318    let s = secs % 60;
1319    if h > 0 {
1320        format!("{h}h{m:02}m")
1321    } else if m > 0 {
1322        format!("{m}m{s:02}s")
1323    } else {
1324        format!("{s}s")
1325    }
1326}
1327
1328/// A fixed-width text bar, `value` proportional to `max` out of `width` cells.
1329///
1330/// Any positive value draws at least one cell, so a short-but-nonzero
1331/// duration is never mistaken for an unmeasured one.
1332fn bar(value: f64, max: f64, width: usize) -> String {
1333    if max <= 0.0 || value <= 0.0 {
1334        return String::new();
1335    }
1336    let cells = ((value / max) * width as f64)
1337        .round()
1338        .clamp(1.0, width as f64) as usize;
1339    "█".repeat(cells)
1340}
1341
1342/// Per-repository summary table: repo name, run count, completion rate.
1343///
1344/// `main.rs`'s `Command::Stats` is what decides *whether* to print this —
1345/// only ahead of the plain (no `--repo`) report, and only when `repos.len() >
1346/// 1` — this function only renders whatever it is handed.
1347pub fn repo_summary(repos: &[RepoStats]) -> String {
1348    let mut s = String::new();
1349    let _ = writeln!(s, "{}", bold("repos"));
1350
1351    // Two different checkouts can share a leaf directory name (`magi` under
1352    // two different roots); disambiguate only those with their parent
1353    // directory rather than paying the extra noise on every row.
1354    let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1355    for r in repos {
1356        *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
1357    }
1358
1359    for r in repos {
1360        let label = if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
1361            match r.repo.parent().and_then(|p| p.file_name()) {
1362                Some(parent) => format!("{} ({})", r.name, parent.to_string_lossy()),
1363                None => r.name.clone(),
1364            }
1365        } else {
1366            r.name.clone()
1367        };
1368        let _ = writeln!(
1369            s,
1370            "  {}  {} runs  {:.0}% completion",
1371            label,
1372            r.stats.totals.runs,
1373            r.stats.totals.completion_rate()
1374        );
1375    }
1376    s
1377}
1378
1379/// Aggregate tables, for `magi stats`.
1380pub fn stats(stats: &Stats) -> String {
1381    let t = &stats.totals;
1382    let mut s = String::new();
1383    let _ = writeln!(s, "{}", bold("runs"));
1384    let _ = writeln!(
1385        s,
1386        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1387        t.runs,
1388        t.merged,
1389        t.ready,
1390        t.blocked,
1391        t.failed,
1392        t.completion_rate()
1393    );
1394    if t.tallied > 0 {
1395        let _ = writeln!(
1396            s,
1397            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1398             {} of those changed a mind, {} converged to unanimous",
1399            t.tallied,
1400            t.split,
1401            t.split_rate(),
1402            t.deliberated,
1403            t.minds_changed,
1404            t.converged
1405        );
1406    }
1407
1408    if !stats.retired_hidden.is_empty() {
1409        let n = stats.retired_hidden.len();
1410        let _ = writeln!(
1411            s,
1412            "{n} retired agent{} hidden: {} (magi stats --all to show)\n",
1413            if n == 1 { "" } else { "s" },
1414            stats.retired_hidden.join(", ")
1415        );
1416    }
1417
1418    let has_competitive = stats.agents.iter().any(|a| a.competitive_entered > 0);
1419    if has_competitive {
1420        let mut comp_agents = stats.agents.clone();
1421        comp_agents.retain(|a| a.competitive_entered > 0);
1422        comp_agents.sort_by(|a, b| {
1423            let a_tier = if a.competitive_entered < 10 { 1 } else { 0 };
1424            let b_tier = if b.competitive_entered < 10 { 1 } else { 0 };
1425            a_tier
1426                .cmp(&b_tier)
1427                .then(
1428                    b.competitive_win_rate()
1429                        .total_cmp(&a.competitive_win_rate()),
1430                )
1431                .then(b.competitive_entered.cmp(&a.competitive_entered))
1432                .then(a.agent.cmp(&b.agent))
1433        });
1434
1435        let _ = writeln!(
1436            s,
1437            "\n{}",
1438            bold("implementation (competitive runs, 2+ candidates)")
1439        );
1440        let _ = writeln!(
1441            s,
1442            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
1443            "agent", "won", "entered", "rate", "empty"
1444        );
1445        for a in &comp_agents {
1446            let low_n_marker = if a.competitive_entered < 10 {
1447                format!(" {}", dim("low n"))
1448            } else {
1449                String::new()
1450            };
1451            let _ = writeln!(
1452                s,
1453                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}{}",
1454                a.agent,
1455                a.competitive_wins,
1456                a.competitive_entered,
1457                a.competitive_win_rate(),
1458                a.empty,
1459                low_n_marker
1460            );
1461        }
1462    }
1463
1464    let has_solo = stats.agents.iter().any(|a| a.solo_entered > 0);
1465    if has_solo {
1466        let mut solo_agents = stats.agents.clone();
1467        solo_agents.retain(|a| a.solo_entered > 0);
1468        solo_agents.sort_by(|a, b| {
1469            let a_tier = if a.solo_entered < 10 { 1 } else { 0 };
1470            let b_tier = if b.solo_entered < 10 { 1 } else { 0 };
1471            a_tier
1472                .cmp(&b_tier)
1473                .then(b.solo_adoption_rate().total_cmp(&a.solo_adoption_rate()))
1474                .then(b.solo_entered.cmp(&a.solo_entered))
1475                .then(a.agent.cmp(&b.agent))
1476        });
1477
1478        let _ = writeln!(
1479            s,
1480            "\n{}",
1481            bold("implementation (solo runs, completion/adoption rate)")
1482        );
1483        let _ = writeln!(
1484            s,
1485            "  {:<14}{:>8}{:>8}{:>8}",
1486            "agent", "adopted", "entered", "rate"
1487        );
1488        for a in &solo_agents {
1489            let low_n_marker = if a.solo_entered < 10 {
1490                format!(" {}", dim("low n"))
1491            } else {
1492                String::new()
1493            };
1494            let _ = writeln!(
1495                s,
1496                "  {:<14}{:>8}{:>8}{:>7.0}%{}",
1497                a.agent,
1498                a.solo_adopted,
1499                a.solo_entered,
1500                a.solo_adoption_rate(),
1501                low_n_marker
1502            );
1503        }
1504    }
1505
1506    if !stats.reviewers.is_empty() {
1507        let _ = writeln!(s, "\n{}", bold("review"));
1508        let _ = writeln!(
1509            s,
1510            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1511            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1512        );
1513        for r in &stats.reviewers {
1514            let _ = writeln!(
1515                s,
1516                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1517                r.agent,
1518                r.rounds,
1519                r.submitted,
1520                r.adopted_per_round(),
1521                r.precision(),
1522                r.unique_rate(),
1523                r.timeout_rate()
1524            );
1525        }
1526    }
1527
1528    if !stats.advisors.is_empty() {
1529        let _ = writeln!(
1530            s,
1531            "\n{} {}",
1532            bold("design deliberation"),
1533            dim(
1534                "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1535            )
1536        );
1537        let _ = writeln!(
1538            s,
1539            "  {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1540            "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1541        );
1542        for a in &stats.advisors {
1543            let _ = writeln!(
1544                s,
1545                "  {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1546                a.agent,
1547                a.seated,
1548                a.proposed,
1549                a.strong,
1550                a.faint,
1551                a.absent,
1552                a.reflection_rate()
1553            );
1554        }
1555    }
1556
1557    if stats.release_bumps.merged > 0 {
1558        let b = &stats.release_bumps;
1559        let _ = writeln!(s, "\n{}", bold("release bumps"));
1560        let _ = writeln!(
1561            s,
1562            "  {} of {} merged run(s) recorded a bump ({:.0}%)",
1563            b.recorded,
1564            b.merged,
1565            b.coverage_rate()
1566        );
1567        let _ = writeln!(
1568            s,
1569            "  {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1570            b.pr_opened,
1571            b.automerge_enabled,
1572            b.automerge_rate(),
1573            b.merged_directly
1574        );
1575        let _ = writeln!(
1576            s,
1577            "  {} clean, {} needed a human ({:.0}%)",
1578            b.clean(),
1579            b.needs_attention,
1580            b.attention_rate()
1581        );
1582    }
1583
1584    if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1585        let _ = writeln!(s, "\n{}", bold("verification"));
1586        let _ = writeln!(
1587            s,
1588            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
1589             review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1590            stats.e2e.rounds,
1591            stats.e2e.failures,
1592            stats.e2e.sole_detections,
1593            stats.e2e.sole_rate(),
1594            stats.e2e.deferred
1595        );
1596    }
1597
1598    if !stats.nodes.is_empty() {
1599        let _ = writeln!(
1600            s,
1601            "\n{}",
1602            bold("node durations (first to last event, per run)")
1603        );
1604        let max_mean = stats
1605            .nodes
1606            .iter()
1607            .map(|n| n.mean_secs())
1608            .fold(0.0_f64, f64::max);
1609        for n in &stats.nodes {
1610            let unmeasured = if n.single > 0 {
1611                format!("  {}", dim(&format!("{} unmeasured", n.single)))
1612            } else {
1613                String::new()
1614            };
1615            let _ = writeln!(
1616                s,
1617                "  {:<14}{:>6} runs{:>8} mean{:>8} max  {}{unmeasured}",
1618                n.node,
1619                n.runs,
1620                fmt_secs(n.mean_secs().round() as i64),
1621                fmt_secs(n.max_secs),
1622                bar(n.mean_secs(), max_mean, 20)
1623            );
1624        }
1625    }
1626    s
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631    use super::*;
1632    use crate::config::Config;
1633    use crate::run::{
1634        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1635        Tally,
1636    };
1637    use std::collections::BTreeMap;
1638    use std::path::PathBuf;
1639    use std::sync::{Mutex, MutexGuard};
1640
1641    /// `COLOR` is process-global, so these tests cannot run concurrently.
1642    static SERIAL: Mutex<()> = Mutex::new(());
1643
1644    fn plain() -> MutexGuard<'static, ()> {
1645        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1646        set_color(false);
1647        guard
1648    }
1649
1650    fn state() -> RunState {
1651        // `run()` prints `state.dir()`, which reads the process-global home;
1652        // pinning it here keeps this test off the operator's real one. The
1653        // directory itself is never read, only its path printed, so nothing
1654        // needs to create or clean it up.
1655        crate::run::pin_test_home();
1656        let mut s = RunState::new(
1657            PathBuf::from("/repo"),
1658            "main".to_owned(),
1659            "abcdef1234".to_owned(),
1660            "add retries to the uploader".to_owned(),
1661            Config::default(),
1662        );
1663        s.candidates = vec![Candidate {
1664            index: 0,
1665            label: 'A',
1666            agent: "opus".to_owned(),
1667            branch: "magi/x/A".to_owned(),
1668            worktree: PathBuf::from("/wt/A"),
1669            summary: String::new(),
1670            stat: String::new(),
1671            files: 3,
1672            commits: 2,
1673            empty: false,
1674            failed: None,
1675            verified_noop: None,
1676            duration_ms: 42_000,
1677            folded: false,
1678        }];
1679        s.tally = Some(Tally {
1680            first_choice: BTreeMap::from([('A', 3)]),
1681            borda: BTreeMap::new(),
1682            winner: 'A',
1683            rankings: 3,
1684            unanimous_initial: true,
1685            deliberated: false,
1686            changed_votes: 0,
1687            unanimous_final: true,
1688            tie_break: None,
1689            judges: 3,
1690            present: 3,
1691            quorum: 2,
1692            met_quorum: true,
1693            uncontested: None,
1694        });
1695        s
1696    }
1697
1698    #[test]
1699    fn run_report_names_the_winner_and_its_author() {
1700        let _guard = plain();
1701        let text = run(&state());
1702        assert!(text.contains("<- winner"), "{text}");
1703        assert!(text.contains("opus"));
1704        assert!(text.contains("3 files, 2 commits"));
1705        assert!(text.contains("winner        A"));
1706        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1707    }
1708
1709    #[test]
1710    fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1711        // Recorded on every run (`prompt::implement`'s `## SUMMARY`), but
1712        // `run()` used to never print it at all — silent even when the
1713        // summary was the one place an implementer explained itself (e.g.
1714        // an investigation task's findings), and readable only by opening
1715        // `run.json` by hand.
1716        let _guard = plain();
1717        let mut s = state();
1718        s.candidates[0].summary =
1719            "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1720                .to_owned();
1721        let text = run(&s);
1722        assert!(
1723            text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1724            "{text}"
1725        );
1726    }
1727
1728    #[test]
1729    fn a_verified_noop_run_does_not_read_as_a_failure() {
1730        // Same spirit as `a_mode_none_merge_does_not_read_as_landed`: a run
1731        // that settled without landing anything must not be misreadable as
1732        // the ordinary failure it is not.
1733        let _guard = plain();
1734        let mut s = state();
1735        s.status = RunStatus::VerifiedNoop;
1736        s.tally = None;
1737        s.candidates = vec![Candidate {
1738            index: 0,
1739            label: 'A',
1740            agent: "opus".to_owned(),
1741            branch: "magi/x/A".to_owned(),
1742            worktree: PathBuf::from("/wt/A"),
1743            summary: String::new(),
1744            stat: String::new(),
1745            files: 0,
1746            commits: 0,
1747            empty: true,
1748            failed: None,
1749            verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1750            duration_ms: 9_000,
1751            folded: false,
1752        }];
1753        let text = run(&s);
1754        assert!(
1755            text.contains("agent-verified no-op"),
1756            "the status and the candidate flag must both say so: {text}"
1757        );
1758        assert!(
1759            text.contains("already fixed by b32cfc4"),
1760            "the evidence itself must be readable, not just the verdict: {text}"
1761        );
1762        assert!(
1763            !text.to_lowercase().contains("failed"),
1764            "a verified no-op must never read as the failure it is not: {text}"
1765        );
1766    }
1767
1768    #[test]
1769    fn colour_is_emitted_only_when_enabled() {
1770        let _guard = plain();
1771        set_color(true);
1772        let coloured = run(&state());
1773        set_color(false);
1774        let plain = run(&state());
1775        assert!(coloured.contains('\x1b'));
1776        assert!(!plain.contains('\x1b'));
1777        assert!(coloured.len() > plain.len());
1778    }
1779
1780    #[test]
1781    fn list_line_is_single_line() {
1782        let _guard = plain();
1783        let l = line(&state());
1784        assert_eq!(l.lines().count(), 1);
1785        assert!(l.contains("add retries"));
1786        assert!(l.contains("win A (opus)"));
1787    }
1788
1789    #[test]
1790    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1791        let _guard = plain();
1792        let mut s = state();
1793        s.tally = Some(Tally {
1794            first_choice: BTreeMap::from([('A', 0)]),
1795            borda: BTreeMap::new(),
1796            winner: 'A',
1797            rankings: 0,
1798            unanimous_initial: false,
1799            deliberated: false,
1800            changed_votes: 0,
1801            unanimous_final: false,
1802            tie_break: None,
1803            judges: 0,
1804            present: 0,
1805            quorum: 0,
1806            met_quorum: true,
1807            uncontested: Some(
1808                "only candidate A produced a usable change; no panel was asked".to_owned(),
1809            ),
1810        });
1811        let text = run(&s);
1812        assert!(
1813            !text.contains("0/3"),
1814            "no panel sat, so the judges line must not read as one that collapsed: {text}"
1815        );
1816        assert!(!text.contains("no usable ranking"), "{text}");
1817        assert!(!text.contains("still split"), "{text}");
1818        assert!(!text.contains("BELOW QUORUM"), "{text}");
1819        assert!(
1820            text.contains("not needed"),
1821            "the report must say judging was skipped, not silent: {text}"
1822        );
1823        assert!(text.contains("winner        A"));
1824    }
1825
1826    #[test]
1827    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1828        let _guard = plain();
1829        let mut s = state();
1830        s.tally = Some(Tally {
1831            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1832            borda: BTreeMap::new(),
1833            winner: 'A',
1834            rankings: 1,
1835            unanimous_initial: false,
1836            deliberated: false,
1837            changed_votes: 0,
1838            unanimous_final: false,
1839            tie_break: None,
1840            judges: 3,
1841            present: 1,
1842            quorum: 2,
1843            met_quorum: false,
1844            uncontested: None,
1845        });
1846        let text = run(&s);
1847        assert!(text.contains("1/3"), "{text}");
1848        assert!(
1849            text.contains("BELOW QUORUM"),
1850            "a real collapse must still be flagged: {text}"
1851        );
1852        assert!(
1853            !text.contains("not needed"),
1854            "a collapsed panel must not be described as one that was never asked: {text}"
1855        );
1856    }
1857
1858    #[test]
1859    fn a_mode_none_merge_does_not_read_as_landed() {
1860        let _guard = plain();
1861        let mut s = state();
1862        s.merge = Some(MergeOutcome {
1863            mode: crate::config::MergeMode::None,
1864            ok: true,
1865            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1866            empty: false,
1867        });
1868        let text = run(&s);
1869        assert!(
1870            !text.contains("  ok"),
1871            "mode none must not be shown as a landed merge: {text}"
1872        );
1873        assert!(text.contains("not landed"), "{text}");
1874        assert!(
1875            text.contains("branch magi/x/A"),
1876            "the report must say what's left behind: {text}"
1877        );
1878        assert!(
1879            text.contains("rebase"),
1880            "the report must point at the hand-landing steps: {text}"
1881        );
1882        assert!(
1883            !text.contains("placeholder subject"),
1884            "the default merge style is `merge`, which never inherits a \
1885             placeholder subject, so the squash caveat must not appear: {text}"
1886        );
1887    }
1888
1889    #[test]
1890    fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1891        let _guard = plain();
1892        let mut s = state();
1893        s.config.merge.style = MergeStyle::Squash;
1894        s.merge = Some(MergeOutcome {
1895            mode: crate::config::MergeMode::None,
1896            ok: true,
1897            detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1898                      retries\""
1899                .to_owned(),
1900            empty: false,
1901        });
1902        let text = run(&s);
1903        assert!(
1904            text.contains("placeholder subject"),
1905            "a squash-style manual merge must warn about the missing message: {text}"
1906        );
1907        assert!(text.contains("--squash"), "{text}");
1908    }
1909
1910    #[test]
1911    fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1912        let _guard = plain();
1913        let mut s = state();
1914        s.status = RunStatus::Ready;
1915        s.merge = Some(MergeOutcome {
1916            mode: crate::config::MergeMode::None,
1917            ok: true,
1918            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1919            empty: false,
1920        });
1921
1922        let list = line(&s);
1923        assert!(
1924            !list.contains(" ready "),
1925            "a mode-none run must not read as a plain ready in `magi list`: {list}"
1926        );
1927        assert!(list.contains("no-op by design"), "{list}");
1928
1929        let full = run(&s);
1930        assert!(
1931            !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1932            "the header line of `magi show` must not say plain ready either: {full}"
1933        );
1934        assert!(full.contains("no-op by design"), "{full}");
1935    }
1936
1937    #[test]
1938    fn an_ordinary_ready_run_still_reads_as_ready() {
1939        let _guard = plain();
1940        let mut s = state();
1941        s.status = RunStatus::Ready;
1942        // A PR closed without merging also ends at `Ready` (see `land.rs`),
1943        // and unlike the honest mode-none no-op it must keep reading as a
1944        // plain `ready` — the label exists to flag design, not every non-merge.
1945        s.merge = Some(MergeOutcome {
1946            mode: crate::config::MergeMode::Pr,
1947            ok: false,
1948            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1949            empty: false,
1950        });
1951
1952        let list = line(&s);
1953        assert!(list.contains("ready"), "{list}");
1954        assert!(!list.contains("no-op by design"), "{list}");
1955    }
1956
1957    #[test]
1958    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1959        let _guard = plain();
1960        let mut s = state();
1961        s.tally = Some(Tally {
1962            first_choice: BTreeMap::from([('A', 0)]),
1963            borda: BTreeMap::new(),
1964            winner: 'A',
1965            rankings: 0,
1966            unanimous_initial: false,
1967            deliberated: false,
1968            changed_votes: 0,
1969            unanimous_final: false,
1970            tie_break: None,
1971            judges: 0,
1972            present: 0,
1973            quorum: 0,
1974            met_quorum: true,
1975            uncontested: Some("only candidate A produced a usable change".to_owned()),
1976        });
1977        let l = line(&s);
1978        assert!(
1979            !l.contains("judges") && !l.contains("quorum"),
1980            "an uncontested run must not carry the same badge a short panel gets: {l}"
1981        );
1982    }
1983
1984    #[test]
1985    fn long_instructions_are_elided() {
1986        let _guard = plain();
1987        let mut s = state();
1988        s.instruction = "x".repeat(200);
1989        assert!(line(&s).contains('…'));
1990    }
1991
1992    #[test]
1993    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1994        let _guard = plain();
1995        let mut lost = state();
1996        lost.reviews = vec![ReviewRound {
1997            round: 1,
1998            head: "abc1234".to_owned(),
1999            verified_head: None,
2000            verified_at: None,
2001            reviews: Vec::new(),
2002            e2e: Vec::new(),
2003            verify_retried: false,
2004            e2e_deferred: false,
2005            e2e_defer_reason: None,
2006            fix: Some(FixRecord {
2007                agent: "opus".to_owned(),
2008                addressed: Vec::new(),
2009                rejected: Vec::new(),
2010                notes: String::new(),
2011                committed: true,
2012                failed: Some("timed out".to_owned()),
2013                duration_ms: 0,
2014                continuation: None,
2015            }),
2016            blocking: 3,
2017            answered: 0,
2018            expected: 0,
2019            clean: false,
2020            progressed: false,
2021            vote_split: false,
2022            reconsideration: Vec::new(),
2023            verdict: None,
2024        }];
2025        let text = run(&lost);
2026        assert!(text.contains("adoption report lost (timed out)"), "{text}");
2027        assert!(
2028            !text.contains("0 addressed"),
2029            "a lost report must never read as `0 addressed`: {text}"
2030        );
2031
2032        let mut rejected_all = state();
2033        rejected_all.reviews = vec![ReviewRound {
2034            round: 1,
2035            head: "abc1234".to_owned(),
2036            verified_head: None,
2037            verified_at: None,
2038            reviews: Vec::new(),
2039            e2e: Vec::new(),
2040            verify_retried: false,
2041            e2e_deferred: false,
2042            e2e_defer_reason: None,
2043            fix: Some(FixRecord {
2044                agent: "opus".to_owned(),
2045                addressed: Vec::new(),
2046                rejected: Vec::new(),
2047                notes: String::new(),
2048                committed: true,
2049                failed: None,
2050                duration_ms: 0,
2051                continuation: None,
2052            }),
2053            blocking: 3,
2054            answered: 0,
2055            expected: 0,
2056            clean: false,
2057            progressed: false,
2058            vote_split: false,
2059            reconsideration: Vec::new(),
2060            verdict: None,
2061        }];
2062        let text2 = run(&rejected_all);
2063        assert!(
2064            text2.contains("0 addressed / 0 rejected"),
2065            "a round the fixer actually reported on keeps the count: {text2}"
2066        );
2067    }
2068
2069    #[test]
2070    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
2071        use crate::run::ReviewRevoteRecord;
2072        use crate::verdict::ReviewVote;
2073
2074        let _guard = plain();
2075        let mut s = state();
2076        s.reviews = vec![ReviewRound {
2077            round: 1,
2078            head: "abc1234".to_owned(),
2079            verified_head: None,
2080            verified_at: None,
2081            reviews: vec![
2082                ReviewRecord {
2083                    attempts: 0,
2084                    reviewer: 1,
2085                    agent: "alpha".to_owned(),
2086                    summary: String::new(),
2087                    findings: Vec::new(),
2088                    vote: Some(ReviewVote::Approve),
2089                    failed: None,
2090                    duration_ms: 0,
2091                },
2092                ReviewRecord {
2093                    attempts: 0,
2094                    reviewer: 2,
2095                    agent: "beta".to_owned(),
2096                    summary: String::new(),
2097                    findings: Vec::new(),
2098                    vote: Some(ReviewVote::Reject),
2099                    failed: None,
2100                    duration_ms: 0,
2101                },
2102            ],
2103            e2e: Vec::new(),
2104            verify_retried: false,
2105            e2e_deferred: false,
2106            e2e_defer_reason: None,
2107            fix: None,
2108            blocking: 0,
2109            answered: 2,
2110            expected: 2,
2111            clean: false,
2112            progressed: false,
2113            vote_split: true,
2114            reconsideration: vec![ReviewRevoteRecord {
2115                reviewer: 2,
2116                agent: "beta".to_owned(),
2117                vote: Some(ReviewVote::ApproveWithFindings),
2118                reason: "the other seat's read holds up".to_owned(),
2119                failed: None,
2120            }],
2121            verdict: Some(ReviewVote::ApproveWithFindings),
2122        }];
2123        let text = run(&s);
2124        assert!(text.contains("review-1 vote"), "{text}");
2125        assert!(text.contains("review-2 vote"), "{text}");
2126        assert!(text.contains("panel split"), "{text}");
2127        assert!(text.contains("reconsideration"), "{text}");
2128        assert!(text.contains("the other seat's read holds up"), "{text}");
2129    }
2130
2131    #[test]
2132    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
2133        // Two independent facts share this one line, and each arrived from a
2134        // different change: a seat that never answered, and a fixer whose
2135        // adoption report was lost. Rendering either must not shadow the
2136        // other, and neither may collapse into the plain `clean`/`open`
2137        // pair the line used to carry.
2138        let _guard = plain();
2139        let mut s = state();
2140        s.reviews = vec![ReviewRound {
2141            round: 1,
2142            head: "abc1234".to_owned(),
2143            verified_head: None,
2144            verified_at: None,
2145            reviews: vec![
2146                ReviewRecord {
2147                    attempts: 0,
2148                    reviewer: 1,
2149                    agent: "alpha".to_owned(),
2150                    summary: String::new(),
2151                    findings: Vec::new(),
2152                    vote: None,
2153                    failed: None,
2154                    duration_ms: 0,
2155                },
2156                ReviewRecord {
2157                    attempts: 0,
2158                    reviewer: 2,
2159                    agent: "beta".to_owned(),
2160                    summary: String::new(),
2161                    findings: Vec::new(),
2162                    vote: None,
2163                    failed: Some("agent timed out".to_owned()),
2164                    duration_ms: 0,
2165                },
2166            ],
2167            e2e: Vec::new(),
2168            verify_retried: false,
2169            e2e_deferred: false,
2170            e2e_defer_reason: None,
2171            fix: Some(FixRecord {
2172                agent: "opus".to_owned(),
2173                addressed: Vec::new(),
2174                rejected: Vec::new(),
2175                notes: String::new(),
2176                committed: true,
2177                failed: Some("timed out".to_owned()),
2178                duration_ms: 0,
2179                continuation: None,
2180            }),
2181            blocking: 0,
2182            answered: 1,
2183            expected: 2,
2184            clean: false,
2185            progressed: true,
2186            vote_split: false,
2187            reconsideration: Vec::new(),
2188            verdict: None,
2189        }];
2190        let text = run(&s);
2191        assert!(text.contains("incomplete"), "{text}");
2192        assert!(text.contains("1/2 reviewers answered"), "{text}");
2193        assert!(text.contains("review-2: agent timed out"), "{text}");
2194        assert!(text.contains("adoption report lost (timed out)"), "{text}");
2195        assert!(
2196            !text.contains("clean"),
2197            "a round missing half its panel must never render as clean: {text}"
2198        );
2199    }
2200
2201    #[test]
2202    fn a_build_failure_is_not_reported_as_a_test_failure() {
2203        let _guard = plain();
2204        let mut s = state();
2205        s.reviews = vec![ReviewRound {
2206            round: 1,
2207            head: "abc1234".to_owned(),
2208            verified_head: None,
2209            verified_at: None,
2210            reviews: Vec::new(),
2211            e2e: vec![CommandOutcome {
2212                command: "cargo test".to_owned(),
2213                code: Some(1),
2214                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2215                duration_ms: 100,
2216                resource_blocked: false,
2217            }],
2218            verify_retried: true,
2219            e2e_deferred: false,
2220            e2e_defer_reason: None,
2221            fix: None,
2222            blocking: 0,
2223            answered: 0,
2224            expected: 0,
2225            clean: false,
2226            progressed: false,
2227            vote_split: false,
2228            reconsideration: Vec::new(),
2229            verdict: None,
2230        }];
2231        let text = run(&s);
2232        assert!(text.contains("could not run"), "{text}");
2233        assert!(text.contains("retried once"), "{text}");
2234        assert!(!text.contains("e2e RED"), "{text}");
2235    }
2236
2237    #[test]
2238    fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2239        let _guard = plain();
2240        let mut s = state();
2241        s.reviews = vec![ReviewRound {
2242            round: 1,
2243            head: "abc1234".to_owned(),
2244            verified_head: None,
2245            verified_at: None,
2246            reviews: Vec::new(),
2247            e2e: vec![CommandOutcome {
2248                command: "(waiting for the shared build cache)".to_owned(),
2249                code: None,
2250                output_tail: "held by run x node e2e seat e2e".to_owned(),
2251                duration_ms: 100,
2252                resource_blocked: true,
2253            }],
2254            verify_retried: false,
2255            e2e_deferred: false,
2256            e2e_defer_reason: None,
2257            fix: None,
2258            blocking: 0,
2259            answered: 0,
2260            expected: 0,
2261            clean: false,
2262            progressed: false,
2263            vote_split: false,
2264            reconsideration: Vec::new(),
2265            verdict: None,
2266        }];
2267        let text = run(&s);
2268        assert!(text.contains("resource unavailable"), "{text}");
2269        assert!(!text.contains("e2e RED"), "{text}");
2270        assert!(!text.contains("build/link failure"), "{text}");
2271    }
2272
2273    #[test]
2274    fn a_round_with_more_than_one_e2e_command_names_each_one() {
2275        // The aggregate `e2e RED` label says the round's overall verdict,
2276        // never which of several `verify.e2e` commands actually failed —
2277        // `magi show` must list each command by name, the same way it
2278        // already does for `gate`.
2279        let _guard = plain();
2280        let mut s = state();
2281        s.reviews = vec![ReviewRound {
2282            round: 1,
2283            head: "abc1234".to_owned(),
2284            verified_head: Some("abc1234".to_owned()),
2285            verified_at: Some(jiff::Timestamp::now()),
2286            reviews: Vec::new(),
2287            e2e: vec![
2288                CommandOutcome {
2289                    command: "cargo test --locked --all-targets".to_owned(),
2290                    code: Some(0),
2291                    output_tail: String::new(),
2292                    duration_ms: 0,
2293                    resource_blocked: false,
2294                },
2295                CommandOutcome {
2296                    command: "cargo make check".to_owned(),
2297                    code: Some(1),
2298                    output_tail: "clippy: unused import".to_owned(),
2299                    duration_ms: 0,
2300                    resource_blocked: false,
2301                },
2302            ],
2303            verify_retried: false,
2304            e2e_deferred: false,
2305            e2e_defer_reason: None,
2306            fix: None,
2307            blocking: 0,
2308            answered: 0,
2309            expected: 0,
2310            clean: false,
2311            progressed: false,
2312            vote_split: false,
2313            reconsideration: Vec::new(),
2314            verdict: None,
2315        }];
2316        let text = run(&s);
2317        assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2318        assert!(text.contains("cargo make check"), "{text}");
2319        assert!(text.contains("clippy: unused import"), "{text}");
2320    }
2321
2322    #[test]
2323    fn a_declined_finding_shows_its_reason() {
2324        use crate::verdict::{Finding, Rejection, Severity};
2325
2326        let _guard = plain();
2327        let mut s = state();
2328        s.status = RunStatus::Ready;
2329        s.reviews = vec![ReviewRound {
2330            round: 1,
2331            head: "deadbee".to_owned(),
2332            verified_head: None,
2333            verified_at: None,
2334            reviews: vec![ReviewRecord {
2335                attempts: 0,
2336                reviewer: 1,
2337                agent: "alpha".to_owned(),
2338                summary: String::new(),
2339                findings: vec![Finding {
2340                    id: "R1-1-1".to_owned(),
2341                    severity: Severity::Major,
2342                    file: None,
2343                    line: None,
2344                    title: "still open".to_owned(),
2345                    detail: String::new(),
2346                }],
2347                vote: None,
2348                failed: None,
2349                duration_ms: 0,
2350            }],
2351            e2e: vec![CommandOutcome {
2352                command: "cargo test".to_owned(),
2353                code: Some(0),
2354                output_tail: String::new(),
2355                duration_ms: 0,
2356                resource_blocked: false,
2357            }],
2358            verify_retried: false,
2359            e2e_deferred: false,
2360            e2e_defer_reason: None,
2361            fix: Some(FixRecord {
2362                agent: "alpha".to_owned(),
2363                addressed: Vec::new(),
2364                rejected: vec![Rejection {
2365                    id: "R1-1-2".to_owned(),
2366                    why: "cannot be triggered from any caller".to_owned(),
2367                }],
2368                notes: String::new(),
2369                committed: true,
2370                failed: None,
2371                duration_ms: 0,
2372                continuation: None,
2373            }),
2374            blocking: 1,
2375            answered: 1,
2376            expected: 1,
2377            clean: false,
2378            progressed: true,
2379            vote_split: false,
2380            reconsideration: Vec::new(),
2381            verdict: None,
2382        }];
2383
2384        let text = run(&s);
2385        assert!(text.contains("R1-1-2"), "{text}");
2386        assert!(text.contains("cannot be triggered"), "{text}");
2387        assert!(text.contains("still open"), "{text}");
2388        assert!(
2389            text.contains("handed off"),
2390            "a mergeable run with an open round must say so: {text}"
2391        );
2392    }
2393
2394    #[test]
2395    fn a_failing_gate_command_shows_its_output() {
2396        let _guard = plain();
2397        let mut s = state();
2398        s.status = RunStatus::Blocked;
2399        s.gate = vec![CommandOutcome {
2400            command: "cargo make check".to_owned(),
2401            code: Some(101),
2402            output_tail: "error[E0308]: mismatched types".to_owned(),
2403            duration_ms: 0,
2404            resource_blocked: false,
2405        }];
2406        s.gate_ran = true;
2407
2408        let text = run(&s);
2409        assert!(text.contains("mismatched types"), "{text}");
2410    }
2411
2412    #[test]
2413    fn a_gate_with_no_commands_is_reported_as_not_tested() {
2414        let _guard = plain();
2415        let mut s = state();
2416        s.status = RunStatus::Ready;
2417        s.gate_ran = true;
2418        assert!(s.gate.is_empty());
2419
2420        let text = run(&s);
2421        assert!(
2422            text.contains("gate")
2423                && text.contains("no gate commands configured")
2424                && text.contains("not tested"),
2425            "an unconfigured gate must be explicit: {text}"
2426        );
2427    }
2428
2429    #[test]
2430    fn a_gate_that_has_not_run_yet_shows_nothing() {
2431        let _guard = plain();
2432        let s = state();
2433        assert!(!s.gate_ran);
2434        assert!(s.gate.is_empty());
2435
2436        let text = run(&s);
2437        assert!(
2438            !text.contains("no gate commands configured"),
2439            "an unattempted gate must not be shown as a pass: {text}"
2440        );
2441    }
2442
2443    #[test]
2444    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2445        let _guard = plain();
2446        let mut s = state();
2447        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2448        let text = active_seats(&s, Liveness::Live);
2449        assert!(text.contains("running now"));
2450        assert!(text.contains("judge-2"));
2451        assert!(text.contains("judge"));
2452        assert!(!text.contains("no live daemon"), "{text}");
2453        assert!(!text.contains("could not be confirmed"), "{text}");
2454    }
2455
2456    #[test]
2457    fn active_seats_flags_a_leftover_from_a_dead_process() {
2458        let _guard = plain();
2459        let mut s = state();
2460        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2461        let text = active_seats(&s, Liveness::Dead);
2462        assert!(
2463            text.contains("no live daemon"),
2464            "a stale entry must not read as running: {text}"
2465        );
2466    }
2467
2468    #[test]
2469    fn superseded_notice_names_the_run_that_replaced_it() {
2470        let _guard = plain();
2471        let text = superseded_notice(Some("bbbb"));
2472        assert!(text.contains("SUPERSEDED"), "{text}");
2473        assert!(text.contains("bbbb"), "{text}");
2474    }
2475
2476    #[test]
2477    fn superseded_notice_is_silent_for_the_latest_attempt() {
2478        let _guard = plain();
2479        assert_eq!(superseded_notice(None), "");
2480    }
2481
2482    #[test]
2483    fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2484        let _guard = plain();
2485        let mut s = state();
2486        s.status = RunStatus::Reviewing;
2487        let text = line_with_liveness(&s, Liveness::Dead);
2488        assert!(text.contains("STALE"), "{text}");
2489        assert!(text.contains("resume required"), "{text}");
2490        assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2491    }
2492
2493    /// A manual `magi run` / `magi review` claims no daemon, but its
2494    /// `driver_pid` still answers — this is `Unknown`, never `Dead`: see
2495    /// `RunState::liveness`'s own doc for why an unconfirmed process is never
2496    /// folded into "confirmed dead".
2497    #[test]
2498    fn active_seats_reports_uncertainty_without_claiming_death() {
2499        let _guard = plain();
2500        let mut s = state();
2501        s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2502        let text = active_seats(&s, Liveness::Unknown);
2503        assert!(
2504            text.contains("could not be confirmed"),
2505            "an unproven state must read as uncertain, not dead: {text}"
2506        );
2507        assert!(!text.contains("no live daemon"), "{text}");
2508    }
2509
2510    #[test]
2511    fn resource_block_reports_saved_evidence_in_text_and_web() {
2512        use crate::run::{ResourceBlock, ResourceBlockCause};
2513        let legacy = serde_json::json!({
2514            "node": "fix", "kind": "shared build cache",
2515            "detail": "the run is parking", "at": "2026-10-24T09:01:51Z"
2516        });
2517        let block: ResourceBlock = serde_json::from_value(legacy).unwrap();
2518        assert_eq!(block.cause, ResourceBlockCause::Unrecorded);
2519        for (cause, detail, expected) in [
2520            (
2521                ResourceBlockCause::Unrecorded,
2522                "the run is parking",
2523                "cancelled for parking",
2524            ),
2525            (ResourceBlockCause::Unrecorded, "busy", "cause unrecorded"),
2526            (
2527                ResourceBlockCause::Other,
2528                "metadata write denied",
2529                "operation blocked",
2530            ),
2531            (
2532                ResourceBlockCause::Timeout {
2533                    waited_secs: 12,
2534                    limit_secs: 10,
2535                },
2536                "busy",
2537                "timed out after 12s (limit 10s)",
2538            ),
2539            (
2540                ResourceBlockCause::Cancelled {
2541                    park: Some(crate::run::ParkCause::Operator),
2542                },
2543                "cancelled",
2544                "cancelled for parking",
2545            ),
2546        ] {
2547            let mut state = state();
2548            state.resource_block = Some(ResourceBlock {
2549                cause: cause.clone(),
2550                detail: detail.into(),
2551                ..block.clone()
2552            });
2553            let text = active_seats(&state, Liveness::Dead);
2554            assert!(text.contains(expected), "{text}");
2555            assert!(
2556                !text.contains("no agent was started"),
2557                "earlier nodes may have run"
2558            );
2559            assert_eq!(
2560                crate::report_view::build_with(&state, Liveness::Dead, None).active_seats,
2561                text
2562            );
2563            let saved = serde_json::to_value(&state.resource_block).unwrap();
2564            let reloaded: ResourceBlock = serde_json::from_value(saved).unwrap();
2565            assert_eq!(reloaded.cause, cause);
2566        }
2567    }
2568
2569    #[test]
2570    fn active_seats_is_empty_when_nothing_is_running() {
2571        let _guard = plain();
2572        assert_eq!(active_seats(&state(), Liveness::Live), "");
2573    }
2574
2575    /// A running `verify.e2e` / `verify.gate` task is shown alongside seats,
2576    /// with which command is currently running and how far through the list
2577    /// it is — the gap the addendum's second item names: a review-only run
2578    /// that spends minutes inside `cargo test` between reviewer answers and
2579    /// the fix's own commit had nothing at all to show for it before this.
2580    #[test]
2581    fn active_seats_shows_a_running_verify_task_and_its_command() {
2582        let _guard = plain();
2583        let mut s = state();
2584        s.task_command(
2585            "e2e",
2586            "verify",
2587            0,
2588            "cargo test",
2589            2,
2590            3,
2591            std::time::Duration::from_secs(600),
2592        );
2593        let text = active_seats(&s, Liveness::Live);
2594        assert!(text.contains("e2e"), "{text}");
2595        assert!(text.contains("(2/3)"), "{text}");
2596        assert!(text.contains("cargo test"), "{text}");
2597    }
2598
2599    #[test]
2600    fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2601        let _guard = plain();
2602        // The common case today (every backend but codex): silence, not a
2603        // clutter line repeated on every single `magi show`.
2604        assert!(!run(&state()).contains("background jobs"));
2605    }
2606
2607    #[test]
2608    fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2609        let _guard = plain();
2610        let mut s = state();
2611        s.config.agents.push(crate::config::AgentSpec {
2612            id: "codex-one".to_owned(),
2613            kind: crate::config::AgentKind::Codex,
2614            model: None,
2615            command: vec!["codex".to_owned()],
2616            extra_args: Vec::new(),
2617            env: BTreeMap::new(),
2618            prompt_delivery: None,
2619            max_turn_secs: None,
2620        });
2621        let text = run(&s);
2622        assert!(
2623            text.contains("background jobs"),
2624            "a run that could report this must not read the same as one that never could: \
2625             {text}"
2626        );
2627        assert!(text.contains("no completed command evidence yet"));
2628    }
2629
2630    #[test]
2631    fn recovered_running_and_unreadable_jobs_are_told_apart() {
2632        let _guard = plain();
2633        let mut s = state();
2634        s.jobs = vec![
2635            crate::run::JobRecord {
2636                node: "implement".to_owned(),
2637                round: None,
2638                seat: "impl-A".to_owned(),
2639                id: "item49".to_owned(),
2640                description: "cargo test --test graph_cached_gate".to_owned(),
2641                checked_at: jiff::Timestamp::now(),
2642                status: crate::run::JobStatus::Completed,
2643                exit_code: Some(0),
2644                result_summary: "test result: 2 passed; 0 failed".to_owned(),
2645                source: "codex".to_owned(),
2646            },
2647            crate::run::JobRecord {
2648                node: "fix".to_owned(),
2649                round: None,
2650                seat: "impl-A".to_owned(),
2651                id: "item52".to_owned(),
2652                description: "cargo test --test graph_split".to_owned(),
2653                checked_at: jiff::Timestamp::now(),
2654                status: crate::run::JobStatus::Failed,
2655                exit_code: Some(101),
2656                result_summary: "test result: 1 passed; 1 failed".to_owned(),
2657                source: "codex".to_owned(),
2658            },
2659            crate::run::JobRecord {
2660                node: "fix".to_owned(),
2661                round: None,
2662                seat: "impl-A".to_owned(),
2663                id: "item60".to_owned(),
2664                description: "cargo build".to_owned(),
2665                checked_at: jiff::Timestamp::now(),
2666                status: crate::run::JobStatus::Unknown,
2667                exit_code: None,
2668                result_summary: String::new(),
2669                source: "codex".to_owned(),
2670            },
2671        ];
2672        let text = run(&s);
2673        assert!(text.contains("background jobs"));
2674        assert!(text.contains("item49"));
2675        assert!(text.contains("item52"));
2676        assert!(text.contains("item60"));
2677        // The three states this run actually has evidence for must read
2678        // differently from one another — never collapsed into a single
2679        // "ran" or "did not run".
2680        assert!(text.contains("completed"));
2681        assert!(text.contains("failed"));
2682        assert!(text.contains("unknown"));
2683        // Coverage limit stated once, not fabricated per seat.
2684        assert!(text.contains("adapter coverage"));
2685    }
2686
2687    #[test]
2688    fn a_jobs_own_round_is_shown_when_known() {
2689        let _guard = plain();
2690        let mut s = state();
2691        s.jobs = vec![crate::run::JobRecord {
2692            node: "review".to_owned(),
2693            round: Some(2),
2694            seat: "review-1".to_owned(),
2695            id: "item9".to_owned(),
2696            description: "cargo test --test graph_cached_gate".to_owned(),
2697            checked_at: jiff::Timestamp::now(),
2698            status: crate::run::JobStatus::Completed,
2699            exit_code: Some(0),
2700            result_summary: "test result: 2 passed; 0 failed".to_owned(),
2701            source: "codex".to_owned(),
2702        }];
2703        let text = run(&s);
2704        assert!(
2705            text.contains("round 2"),
2706            "the round this seat's own command ran in must be visible, distinct from magi's \
2707             own recorded verify: {text}"
2708        );
2709    }
2710
2711    #[test]
2712    fn stats_table_renders_without_runs() {
2713        let _guard = plain();
2714        let text = stats(&Stats::default());
2715        assert!(text.contains("0 total"));
2716        assert!(!text.contains("implementation"));
2717        assert!(!text.contains("node durations"));
2718    }
2719
2720    #[test]
2721    fn repo_summary_lists_run_count_and_completion_rate() {
2722        let _guard = plain();
2723        let a = crate::stats::RepoStats {
2724            repo: PathBuf::from("/repos/a"),
2725            name: "a".to_owned(),
2726            stats: Stats {
2727                totals: crate::stats::Totals {
2728                    runs: 4,
2729                    merged: 2,
2730                    ready: 1,
2731                    ..Default::default()
2732                },
2733                ..Default::default()
2734            },
2735        };
2736        let text = repo_summary(&[a]);
2737        assert!(text.contains("repos"), "{text}");
2738        assert!(text.contains("a  4 runs  75% completion"), "{text}");
2739    }
2740
2741    #[test]
2742    fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2743        let _guard = plain();
2744        let a = crate::stats::RepoStats {
2745            repo: PathBuf::from("/roots/one/magi"),
2746            name: "magi".to_owned(),
2747            stats: Stats::default(),
2748        };
2749        let b = crate::stats::RepoStats {
2750            repo: PathBuf::from("/roots/two/magi"),
2751            name: "magi".to_owned(),
2752            stats: Stats::default(),
2753        };
2754        let text = repo_summary(&[a, b]);
2755        assert!(text.contains("magi (one)"), "{text}");
2756        assert!(text.contains("magi (two)"), "{text}");
2757    }
2758
2759    #[test]
2760    fn stats_table_renders_advisor_reflection() {
2761        let _guard = plain();
2762        let st = Stats {
2763            advisors: vec![crate::stats::AdvisorStats {
2764                agent: "alpha".to_owned(),
2765                seated: 3,
2766                proposed: 2,
2767                absent: 1,
2768                faint: 1,
2769                strong: 1,
2770            }],
2771            ..Default::default()
2772        };
2773        let text = stats(&st);
2774        assert!(text.contains("design deliberation"));
2775        assert!(
2776            text.contains("approximate"),
2777            "must note the heuristic: {text}"
2778        );
2779        assert!(text.contains("alpha"));
2780        assert!(text.contains("50%"));
2781    }
2782
2783    #[test]
2784    fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2785        // A repository that has merged runs but never wired up (or never hit)
2786        // the release-bump step must still show "0 of N merged runs recorded
2787        // a bump" - hiding the whole section on `recorded == 0` would make
2788        // that gap invisible instead of reporting it.
2789        let _guard = plain();
2790        let st = Stats {
2791            release_bumps: crate::stats::ReleaseBumpStats {
2792                merged: 3,
2793                ..Default::default()
2794            },
2795            ..Default::default()
2796        };
2797        let text = stats(&st);
2798        assert!(text.contains("release bumps"), "{text}");
2799        assert!(
2800            text.contains("0 of 3 merged run(s) recorded a bump"),
2801            "{text}"
2802        );
2803    }
2804
2805    #[test]
2806    fn stats_table_renders_release_bump_coverage_and_attention() {
2807        let _guard = plain();
2808        let st = Stats {
2809            release_bumps: crate::stats::ReleaseBumpStats {
2810                merged: 4,
2811                recorded: 2,
2812                pr_opened: 2,
2813                automerge_enabled: 1,
2814                merged_directly: 0,
2815                needs_attention: 1,
2816            },
2817            ..Default::default()
2818        };
2819        let text = stats(&st);
2820        assert!(text.contains("release bumps"), "{text}");
2821        assert!(
2822            text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2823            "{text}"
2824        );
2825        assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2826    }
2827
2828    #[test]
2829    fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2830        let _guard = plain();
2831        let st = Stats {
2832            nodes: vec![
2833                crate::stats::NodeDuration {
2834                    node: "implement".to_owned(),
2835                    runs: 2,
2836                    total_secs: 200,
2837                    max_secs: 150,
2838                    single: 1,
2839                },
2840                crate::stats::NodeDuration {
2841                    node: "gate".to_owned(),
2842                    runs: 0,
2843                    total_secs: 0,
2844                    max_secs: 0,
2845                    single: 3,
2846                },
2847            ],
2848            ..Default::default()
2849        };
2850        let text = stats(&st);
2851        assert!(text.contains("node durations"));
2852        assert!(text.contains("implement"));
2853        assert!(text.contains("2 runs"));
2854        assert!(text.contains('█'), "the leader must draw a bar: {text}");
2855        assert!(text.contains("1 unmeasured"));
2856        assert!(text.contains("gate"));
2857        assert!(text.contains("3 unmeasured"));
2858        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2859    }
2860
2861    #[test]
2862    fn implementation_rates_use_independent_low_n_boundaries() {
2863        let _guard = plain();
2864        for (competitive_n, solo_n) in [(9, 10), (10, 9)] {
2865            let text = stats(&Stats {
2866                agents: vec![crate::stats::AgentStats {
2867                    agent: "alpha".to_owned(),
2868                    competitive_entered: competitive_n,
2869                    competitive_wins: 1,
2870                    solo_entered: solo_n,
2871                    solo_adopted: 2,
2872                    ..Default::default()
2873                }],
2874                ..Default::default()
2875            });
2876            let (competitive, solo) = text
2877                .split_once("implementation (solo runs, completion/adoption rate)")
2878                .unwrap();
2879            for (section, n, numerator) in [(competitive, competitive_n, 1), (solo, solo_n, 2)] {
2880                let row = section.lines().find(|line| line.contains("alpha")).unwrap();
2881                let cells: Vec<_> = row.split_whitespace().collect();
2882                assert_eq!(cells[1], numerator.to_string());
2883                assert_eq!(cells[2], n.to_string());
2884                assert_eq!(row.contains("low n"), n < 10);
2885            }
2886        }
2887    }
2888
2889    #[test]
2890    fn stats_table_renders_competitive_and_solo_tables_with_low_n() {
2891        let _guard = plain();
2892        let st = Stats {
2893            agents: vec![
2894                crate::stats::AgentStats {
2895                    agent: "alpha".to_owned(),
2896                    entered: 15,
2897                    wins: 10,
2898                    empty: 0,
2899                    competitive_entered: 12,
2900                    competitive_wins: 9,
2901                    solo_entered: 3,
2902                    solo_adopted: 1,
2903                },
2904                crate::stats::AgentStats {
2905                    agent: "beta".to_owned(),
2906                    entered: 5,
2907                    wins: 2,
2908                    empty: 1,
2909                    competitive_entered: 4,
2910                    competitive_wins: 2,
2911                    solo_entered: 1,
2912                    solo_adopted: 0,
2913                },
2914            ],
2915            ..Default::default()
2916        };
2917        let text = stats(&st);
2918        assert!(text.contains("implementation (competitive runs, 2+ candidates)"));
2919        assert!(text.contains("alpha"));
2920        assert!(text.contains("75%"));
2921        assert!(text.contains("beta"));
2922        assert!(text.contains("50%"));
2923        assert!(text.contains("implementation (solo runs, completion/adoption rate)"));
2924        assert!(text.contains("33%"));
2925        assert!(text.contains("low n"));
2926    }
2927}