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