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