Skip to main content

magi/
report.rs

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