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::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        _ => cyan(text),
86    }
87}
88
89/// Colour for a reviewer vote — the same scale a finding's severity gets:
90/// green for no reservations, yellow for proceed-but-look-at-this, red for a
91/// vote that says stop.
92fn vote_tag(vote: ReviewVote) -> String {
93    let text = vote.label();
94    match vote {
95        ReviewVote::Approve => green(text),
96        ReviewVote::ApproveWithFindings => yellow(text),
97        ReviewVote::Reject => red(text),
98    }
99}
100
101/// One-line summary, for `magi list`.
102pub fn line(state: &RunState) -> String {
103    line_with_liveness(state, Liveness::Unknown)
104}
105
106/// [`line`] with the independent process-liveness observation available to
107/// `magi list`.
108pub fn line_with_liveness(state: &RunState, live: Liveness) -> String {
109    let winner = state
110        .tally
111        .as_ref()
112        .map_or("-".to_owned(), |t| t.winner.to_string());
113    let agent = state.winner().map_or("-", |c| c.agent.as_str());
114    // A below-quorum verdict carries an explicit stamp so a row in a listing
115    // reads "stalled" and "2/3 judges" without opening the report.
116    let quorum = match state.tally.as_ref() {
117        Some(t) if !t.met_quorum => format!(
118            "  {}",
119            bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
120        ),
121        Some(t) if t.present > 0 && t.present < t.judges => format!(
122            "  {}",
123            yellow(&format!("judges {}/{}", t.present, t.judges))
124        ),
125        _ => String::new(),
126    };
127    let stale = if !state.status.done() && live == Liveness::Dead {
128        format!(
129            "  {}",
130            bold(&yellow("STALE — driver exited; resume required"))
131        )
132    } else {
133        String::new()
134    };
135    format!(
136        "{}  {:<20}  {:>2}c {:>2}j  win {} ({}){quorum}{stale}  {}",
137        dim(&state.id),
138        status_word(state),
139        state.candidates.len(),
140        state.judgements.len(),
141        winner,
142        agent,
143        first_line(&state.instruction)
144    )
145}
146
147/// A plainly visible verdict for a non-terminal run whose recorded driver is
148/// gone. Kept separate from [`active_seats`] because a process can die at a
149/// node boundary with no active seat left in the saved state.
150pub fn liveness_notice(state: &RunState, live: Liveness) -> String {
151    if !state.status.done() && live == Liveness::Dead {
152        format!(
153            "{}\n\n",
154            yellow("STALE — the process driving this run exited; resume it to continue.")
155        )
156    } else {
157        String::new()
158    }
159}
160
161/// A plainly visible note for a run a later attempt at the same task already
162/// replaced — the terminal counterpart of the web UI's "Superseded by …"
163/// line. `later` is the short id of that later attempt, or `None` when this
164/// run is not superseded at all (the common case).
165///
166/// Kept as its own function, concatenated by the caller, rather than an
167/// argument threaded through [`run`] itself: `run` is called from `bump.rs`,
168/// `tui.rs` and every `magi show`/`magi list` path with no notion of "what
169/// replaced this", and adding a parameter there would mean guessing `None`
170/// at every one of those call sites instead of only at the one that knows.
171pub fn superseded_notice(later: Option<&str>) -> String {
172    match later {
173        Some(later) => format!(
174            "{}\n\n",
175            yellow(&format!(
176                "SUPERSEDED — a later attempt at this task ({later}) replaced it; this record \
177                 is kept but is not the one to act on."
178            ))
179        ),
180        None => String::new(),
181    }
182}
183
184fn first_line(text: &str) -> String {
185    let line = text.lines().next().unwrap_or_default();
186    if line.chars().count() > 68 {
187        format!("{}…", line.chars().take(67).collect::<String>())
188    } else {
189        line.to_owned()
190    }
191}
192
193fn short(commit: &str) -> String {
194    commit.chars().take(7).collect()
195}
196
197/// A short badge for a [`crate::run::ContinuationRecord`], distinguishing
198/// "the seat's own report needed to be resumed" from "there was nothing to
199/// address" — a fixer that has never needed this stays silent here, exactly
200/// as a record predating the feature (`continuation: None`) does too.
201fn continuation_note(c: &crate::run::ContinuationRecord) -> String {
202    match c.outcome {
203        ContinuationOutcome::NotNeeded => String::new(),
204        ContinuationOutcome::Resumed => format!(" [resumed x{}]", c.attempts),
205        ContinuationOutcome::Exhausted => format!(" [continuation exhausted x{}]", c.attempts),
206        ContinuationOutcome::QuotaLost => " [continuation: quota]".to_owned(),
207        ContinuationOutcome::NoSession => " [no session to resume]".to_owned(),
208    }
209}
210
211/// Commands seats' own CLIs reported running, across every node — see
212/// [`crate::run::JobRecord`]. Read-only: renders whatever `run.json` already
213/// holds, does not query anything live and does not spawn an agent.
214///
215/// A CLI this crate has no adapter for (every backend but Codex, as of this
216/// writing) never appears here at all — silence is "no evidence", not "no
217/// jobs ran", which the trailing coverage line exists to say once rather
218/// than per seat.
219///
220/// There is deliberately no "running" state anywhere in this. A command a
221/// CLI never reported finishing has no way to be told apart from one that
222/// never started at all: the CLI that would report it has, by construction,
223/// already stopped talking (killed by a timeout, or a park) by the time that
224/// question matters, so no event — real or guessed at — could ever answer
225/// it. Guessing at an unconfirmed event shape to manufacture a "running"
226/// entry is exactly the fabrication this feature must not do; recording that
227/// limit instead is what the task asks for here. `magi show`'s own
228/// seat-completion state ([`active_seats`]) still answers a related but
229/// different question honestly — "has this seat's own turn answered yet" —
230/// and stays the right place to look for that.
231fn jobs_section(state: &RunState) -> String {
232    let mut s = String::new();
233    if state.jobs.is_empty() {
234        // Not silent when it would matter: a run whose roster can actually
235        // report this (Codex, today) but has not yet says so explicitly, so
236        // "no adapter for this backend" and "nothing reported yet" are never
237        // the same blank space to a reader.
238        if state
239            .config
240            .agents
241            .iter()
242            .any(|a| a.kind == crate::config::AgentKind::Codex)
243        {
244            let _ = writeln!(
245                s,
246                "\n{}",
247                dim(
248                    "background jobs: no completed command evidence yet for this run (see \
249                     active seats above for what is still mid-turn)"
250                )
251            );
252        }
253        return s;
254    }
255    let _ = writeln!(
256        s,
257        "\n{}",
258        bold("background jobs (from each seat's own CLI)")
259    );
260    let mut by_seat: std::collections::BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> =
261        std::collections::BTreeMap::new();
262    for j in &state.jobs {
263        by_seat
264            .entry((j.node.as_str(), j.seat.as_str()))
265            .or_default()
266            .push(j);
267    }
268    for ((node, seat), records) in by_seat {
269        let _ = writeln!(s, "  {node}/{seat}");
270        for j in records {
271            let status = match j.status {
272                JobStatus::Completed => green("completed"),
273                JobStatus::Failed => red("failed"),
274                JobStatus::Unknown => yellow("unknown"),
275            };
276            let _ = writeln!(
277                s,
278                "    {} {}{}{}  checked {}",
279                dim(&j.id),
280                status,
281                j.exit_code
282                    .map_or(String::new(), |c| format!(" (exit {c})")),
283                j.round.map_or(String::new(), |r| format!("  round {r}")),
284                j.checked_at
285                    .to_zoned(jiff::tz::TimeZone::system())
286                    .strftime("%Y-%m-%d %H:%M:%S")
287            );
288            let desc = first_line(&j.description);
289            if !desc.trim().is_empty() {
290                let _ = writeln!(s, "      $ {desc}");
291            }
292            let summary = first_line(&j.result_summary);
293            if !summary.trim().is_empty() {
294                let _ = writeln!(s, "      {}", dim(&summary));
295            }
296        }
297    }
298    let _ = writeln!(
299        s,
300        "  {}",
301        dim(
302            "(adapter coverage: codex only today; other backends, and a command a CLI never \
303             reported finishing, leave no entry here — that is unknown, never \"nothing ran\")"
304        )
305    );
306    s
307}
308
309/// Full report for one run.
310pub fn run(state: &RunState) -> String {
311    let mut s = String::new();
312    let _ = writeln!(
313        s,
314        "{} {}  {}",
315        bold("magi run"),
316        bold(&state.id),
317        status_word(state)
318    );
319    let _ = writeln!(
320        s,
321        "  repo    {} ({} @ {})",
322        state.repo.display(),
323        state.base_branch,
324        short(&state.base_commit)
325    );
326    let _ = writeln!(s, "  created {}", state.created_local());
327    let _ = writeln!(s, "  task    {}", first_line(&state.instruction));
328    let _ = writeln!(s, "  state   {}", state.dir().display());
329
330    let _ = writeln!(s, "\n{}", bold("candidates"));
331    for c in &state.candidates {
332        let flag = match (&c.failed, c.empty, &c.verified_noop) {
333            (Some(e), _, _) => red(&format!("failed: {e}")),
334            // Neither red (nothing failed) nor plain yellow "no change" (that
335            // reads as an unexplained loss): the candidate gave a reason a
336            // human still has to check, not a claim magi itself confirmed.
337            (None, true, Some(_)) => cyan("agent-verified no-op (unconfirmed)"),
338            (None, true, None) => yellow("no change"),
339            _ => format!("{} files, {} commits", c.files, c.commits),
340        };
341        let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
342            bold(&green("  <- winner"))
343        } else {
344            String::new()
345        };
346        let _ = writeln!(
347            s,
348            "  {}  {:<12} {:<30} {:>5}s{}",
349            bold(&c.label.to_string()),
350            c.agent,
351            flag,
352            c.duration_ms / 1000,
353            crown
354        );
355        if let Some(evidence) = &c.verified_noop {
356            let _ = writeln!(s, "      {}", dim(&first_line(evidence)));
357        } else if !c.summary.trim().is_empty() {
358            // The candidate's own account of what it did and why — the
359            // "## SUMMARY" `prompt::implement` asks for — was recorded on
360            // every run but never surfaced here, which left `magi show`
361            // silent about it even when the summary was the whole point (an
362            // implementer explaining *why* it wrote nothing, short of a
363            // verified no-op's own line above). One line, matching the
364            // house style other prose fields get in this report (see the
365            // review findings' `detail` below); the rest is in `run.json`.
366            let _ = writeln!(s, "      {}", dim(&first_line(&c.summary)));
367        }
368    }
369
370    if !state.judgements.is_empty() {
371        let _ = writeln!(s, "\n{}", bold("blind judging"));
372        for j in &state.judgements {
373            match &j.failed {
374                Some(e) => {
375                    let _ = writeln!(
376                        s,
377                        "  judge {}  {}",
378                        j.judge,
379                        red(&format!("no ranking: {e}"))
380                    );
381                }
382                None => {
383                    let _ = writeln!(
384                        s,
385                        "  judge {}  {:<12} {}  confidence {}",
386                        j.judge,
387                        j.agent,
388                        bold(&j.ranking.iter().collect::<String>()),
389                        j.confidence.map_or("-".to_owned(), |c| c.to_string())
390                    );
391                }
392            }
393        }
394    }
395
396    if let Some(t) = &state.tally {
397        if t.deliberated {
398            let _ = writeln!(s, "\n{}", bold("deliberation"));
399            for round in &state.deliberation {
400                for turn in &round.turns {
401                    let _ = writeln!(
402                        s,
403                        "  r{} judge {} -> {}",
404                        round.round,
405                        turn.judge,
406                        turn.tentative.map_or("-".to_owned(), |c| c.to_string())
407                    );
408                }
409            }
410        }
411
412        if !state.votes.is_empty() {
413            let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
414            for v in &state.votes {
415                let _ = writeln!(
416                    s,
417                    "  judge {}  {:<12} {}{}",
418                    v.judge,
419                    v.agent,
420                    bold(&v.vote.unwrap_or('?').to_string()),
421                    if v.changed {
422                        yellow("  (changed after deliberation)")
423                    } else {
424                        String::new()
425                    }
426                );
427            }
428        }
429
430        let _ = writeln!(s, "\n{}", bold("tally"));
431        // A tally with no panel (`uncontested`) must not fall through the
432        // judges/first-choice/after-votes lines below: they are written
433        // unconditionally and every one of them reads, in the words a panel
434        // that collapsed would also produce, as a run that lost its judges
435        // rather than one that never needed them.
436        match &t.uncontested {
437            Some(reason) => {
438                let _ = writeln!(
439                    s,
440                    "  judging       {}",
441                    cyan(&format!("not needed — {reason}"))
442                );
443            }
444            None => {
445                let _ = writeln!(
446                    s,
447                    "  judges        {} present{}",
448                    if t.met_quorum {
449                        green(&format!("{}/{}", t.present, t.judges))
450                    } else {
451                        red(&format!("{}/{}", t.present, t.judges))
452                    },
453                    if t.quorum > 0 {
454                        format!(" ({quorum} required)", quorum = t.quorum)
455                    } else {
456                        String::new()
457                    }
458                );
459                if !t.met_quorum {
460                    let _ = writeln!(
461                        s,
462                        "  {}",
463                        bold(&red("BELOW QUORUM — verdict is not trustworthy"))
464                    );
465                }
466                let _ = writeln!(
467                    s,
468                    "  first choice  {}",
469                    t.first_choice
470                        .iter()
471                        .map(|(k, v)| format!("{k}:{v}"))
472                        .collect::<Vec<_>>()
473                        .join("  ")
474                );
475                let _ = writeln!(
476                    s,
477                    "  initial       {}",
478                    match (t.rankings, t.unanimous_initial) {
479                        (0, _) => red("no usable ranking"),
480                        (1, _) => yellow("one usable ranking - not a consensus"),
481                        (_, true) => green("unanimous"),
482                        (_, false) => yellow("split"),
483                    }
484                );
485                let _ = writeln!(
486                    s,
487                    "  after votes   {}  ({} judge(s) moved)",
488                    if t.unanimous_final {
489                        green("unanimous")
490                    } else {
491                        yellow("still split")
492                    },
493                    t.changed_votes
494                );
495                if let Some(tb) = &t.tie_break {
496                    let _ = writeln!(s, "  tie break     {tb}");
497                }
498            }
499        }
500        if !state.quota.is_empty() {
501            let _ = writeln!(
502                s,
503                "  rate limited  {}",
504                state
505                    .quota
506                    .iter()
507                    .map(|q| q.seat.as_str())
508                    .collect::<Vec<_>>()
509                    .join(", ")
510            );
511        }
512        let _ = writeln!(s, "  winner        {}", bold(&green(&t.winner.to_string())));
513    }
514
515    if !state.withheld.is_empty() {
516        let _ = writeln!(s, "\n{}", bold("withheld from commit"));
517        for w in &state.withheld {
518            let _ = writeln!(
519                s,
520                "  {} {}  ({} lockfile; the directory uses {})",
521                yellow("!"),
522                w.path,
523                w.manager,
524                w.kept_by
525            );
526        }
527    }
528
529    if !state.reviews.is_empty() {
530        let _ = writeln!(s, "\n{}", bold("review + verification"));
531        for r in &state.reviews {
532            let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
533            // A build/link failure is not a verdict on the patch (a shared
534            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
535            // must not read the same as a real test failure. And a deferred
536            // round is not a passed one: `e2e.is_empty()` alone cannot tell
537            // "not configured" from "skipped on purpose" apart, which is
538            // exactly why `e2e_status` exists rather than reading `e2e`
539            // directly here.
540            let e2e = match r.e2e_status() {
541                E2eStatus::NotConfigured => dim("no e2e"),
542                E2eStatus::Deferred => yellow(&format!(
543                    "e2e deferred{}",
544                    r.e2e_defer_reason
545                        .as_deref()
546                        .map(|why| format!(" ({why})"))
547                        .unwrap_or_default()
548                )),
549                E2eStatus::Passed => green("e2e green"),
550                E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
551                    yellow("e2e could not run (build/link failure)")
552                }
553                E2eStatus::Failed => red("e2e RED"),
554                // Magi's own admission it could not get a command to run —
555                // never the same yellow/red a real attempt earns, since
556                // nothing here is evidence about the patch (see
557                // `E2eStatus::ResourceBlocked`'s own doc).
558                E2eStatus::ResourceBlocked => {
559                    yellow("e2e could not run (shared build cache unavailable)")
560                }
561            };
562            let e2e = if r.verify_retried {
563                format!("{e2e}, retried once")
564            } else {
565                e2e
566            };
567            // Three distinct facts, not two: a round can be *open* (blocking
568            // findings still standing), *incomplete* (a seat never answered,
569            // so what the round says is missing input) or genuinely clean.
570            let status = if r.incomplete() {
571                yellow("incomplete")
572            } else if r.clean {
573                green("clean")
574            } else {
575                yellow("open")
576            };
577            // A missing seat must stay visible even when `warn` policy let
578            // the round gate as clean: the reader should never have to take
579            // "clean" on faith when the panel wasn't full.
580            let panel = if r.incomplete() {
581                let missing: Vec<String> = r
582                    .reviews
583                    .iter()
584                    .filter_map(|x| {
585                        x.failed
586                            .as_ref()
587                            .map(|why| format!("review-{}: {why}", x.reviewer))
588                    })
589                    .collect();
590                format!(
591                    "  {}/{} reviewers answered ({})",
592                    r.answered,
593                    r.expected,
594                    missing.join(", ")
595                )
596            } else {
597                String::new()
598            };
599            // The verdict is the one thing this loop cannot derive from
600            // `blocking`/`e2e` alone: three seats can agree there is nothing
601            // blocking and still split on whether the patch is fine to
602            // proceed as-is, which is exactly the disagreement a vote exists
603            // to surface.
604            let verdict = r.verdict.map_or(String::new(), |v| {
605                format!(
606                    ", verdict {}{}",
607                    vote_tag(v),
608                    if r.vote_split { " (panel split)" } else { "" }
609                )
610            });
611            let _ = writeln!(
612                s,
613                "  round {}  {} @ {}{}{panel}  {raised} finding(s), {} blocking, {e2e}{verdict}{}",
614                r.round,
615                status,
616                short(&r.head),
617                r.verified_head.as_ref().map_or(String::new(), |head| {
618                    format!(
619                        " (verified @ {}{})",
620                        short(head),
621                        r.verified_at.map_or(String::new(), |t| format!(
622                            " on {}",
623                            t.to_zoned(jiff::tz::TimeZone::system())
624                                .strftime("%Y-%m-%d %H:%M:%S")
625                        ))
626                    )
627                }),
628                r.blocking,
629                r.fix.as_ref().map_or(String::new(), |f| {
630                    let tree = if r.progressed {
631                        green("changed")
632                    } else {
633                        yellow("unchanged")
634                    };
635                    let cont = f
636                        .continuation
637                        .as_ref()
638                        .map_or(String::new(), continuation_note);
639                    match &f.failed {
640                        // Never the same shape as "N addressed / M rejected": the
641                        // fixer's diff may well have landed (see the `fix` node's
642                        // own event), but whether it addressed anything is
643                        // unknown, not zero.
644                        Some(reason) => format!(
645                            "  fix: {}, tree {tree}{}{cont}",
646                            yellow(&format!("adoption report lost ({reason})")),
647                            if f.committed {
648                                String::new()
649                            } else {
650                                red(" (NO COMMIT)")
651                            }
652                        ),
653                        None => format!(
654                            "  fix: {} addressed / {} rejected, tree {tree}{}{cont}",
655                            f.addressed.len(),
656                            f.rejected.len(),
657                            if f.committed {
658                                String::new()
659                            } else {
660                                red(" (NO COMMIT)")
661                            }
662                        ),
663                    }
664                })
665            );
666            // The aggregate label above (`e2e`) says the round's overall
667            // verdict; it never named the commands themselves, so a round
668            // with more than one `verify.e2e` command left no way to tell
669            // which one actually failed or was blocked without opening
670            // `run.json` by hand — the same gap `gate`'s own per-command
671            // listing above already closes for the final gate.
672            for o in &r.e2e {
673                let label = if o.resource_blocked {
674                    yellow("blocked")
675                } else if o.ok() {
676                    green("pass")
677                } else {
678                    red("FAIL")
679                };
680                let _ = writeln!(s, "    {label}  {}", o.command);
681                if !o.ok() {
682                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
683                }
684            }
685            for rec in &r.reviews {
686                if let Some(vote) = rec.vote {
687                    let _ = writeln!(s, "      review-{} vote {}", rec.reviewer, vote_tag(vote));
688                }
689                for f in &rec.findings {
690                    let adopted = r
691                        .fix
692                        .as_ref()
693                        .is_some_and(|fix| fix.addressed.contains(&f.id));
694                    let _ = writeln!(
695                        s,
696                        "      {} [{:?}] {}{}",
697                        dim(&f.id),
698                        f.severity,
699                        f.title,
700                        if adopted {
701                            green("  fixed")
702                        } else {
703                            String::new()
704                        }
705                    );
706                }
707            }
708            if let Some(fix) = &r.fix {
709                for rej in &fix.rejected {
710                    let _ = writeln!(
711                        s,
712                        "      {} {}: {}",
713                        dim(&rej.id),
714                        yellow("declined"),
715                        rej.why
716                    );
717                }
718            }
719            // Reconsideration only ever has entries when the round's initial
720            // votes split — an empty list here means the panel agreed the
721            // first time, same as an empty `deliberation` for judges.
722            if !r.reconsideration.is_empty() {
723                let _ = writeln!(s, "      {}", dim("reconsideration:"));
724                for rv in &r.reconsideration {
725                    match rv.vote {
726                        Some(v) => {
727                            let _ = writeln!(
728                                s,
729                                "        review-{} -> {}  {}",
730                                rv.reviewer,
731                                vote_tag(v),
732                                rv.reason
733                            );
734                        }
735                        None => {
736                            let _ = writeln!(
737                                s,
738                                "        review-{} -> {}",
739                                rv.reviewer,
740                                red(&format!(
741                                    "no revote ({})",
742                                    rv.failed.as_deref().unwrap_or("unknown")
743                                ))
744                            );
745                        }
746                    }
747                }
748            }
749        }
750        if state.handed_off_with_open_findings() {
751            let _ = writeln!(
752                s,
753                "\n  {}",
754                yellow(&format!(
755                    "handed off with {} finding(s) still open — gate and e2e were green; \
756                     see above for what a person should still look at",
757                    state.open_findings().len()
758                ))
759            );
760        }
761    }
762
763    if !state.operator_fixes.is_empty() {
764        let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
765        for (i, req) in state.operator_fixes.iter().enumerate() {
766            let _ = writeln!(
767                s,
768                "  [{}] {} finding(s) at {}{}",
769                i + 1,
770                req.findings.len(),
771                req.requested_at
772                    .to_zoned(jiff::tz::TimeZone::system())
773                    .strftime("%Y-%m-%d %H:%M:%S"),
774                if req.stale {
775                    yellow("  stale head, --allow-stale used")
776                } else {
777                    String::new()
778                }
779            );
780            let _ = writeln!(s, "      reason: {}", req.reason);
781            for f in &req.findings {
782                let outcome = match &f.outcome {
783                    OperatorFixOutcome::Pending => yellow("pending"),
784                    OperatorFixOutcome::Addressed => green("addressed"),
785                    OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
786                    OperatorFixOutcome::Unreported => {
787                        red("unreported — no adoption report came back")
788                    }
789                };
790                let _ = writeln!(
791                    s,
792                    "      {} [{:?}] {}  {outcome}",
793                    dim(&f.id),
794                    f.severity,
795                    f.title
796                );
797            }
798            match &req.follow_up_review_run {
799                Some(id) => {
800                    let _ = writeln!(s, "      re-verified by run {id}");
801                }
802                None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
803                    let _ = writeln!(
804                        s,
805                        "      {}",
806                        red("committed, but the follow-up review could not be opened")
807                    );
808                }
809                None => {
810                    let _ = writeln!(s, "      no change committed; nothing to re-verify");
811                }
812            }
813        }
814    }
815
816    if let Some(bs) = &state.base_sync {
817        let _ = writeln!(s, "\n{}", bold("base sync"));
818        let status = if let Some(c) = &bs.conflict {
819            red(&format!("conflict: {}", first_line(c)))
820        } else if bs.behind == 0 {
821            green("in sync")
822        } else {
823            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
824        };
825        let _ = writeln!(
826            s,
827            "  {} @ {}  {status}{}",
828            state.base_branch,
829            short(&bs.tip),
830            if bs.attempts > 0 {
831                format!("  ({} rebase attempt(s))", bs.attempts)
832            } else {
833                String::new()
834            }
835        );
836    }
837
838    if !state.pre_gate.is_empty() {
839        let _ = writeln!(s, "\n{}", bold("pre_gate"));
840        for o in &state.pre_gate {
841            let _ = writeln!(
842                s,
843                "  {}  {}",
844                if o.ok() {
845                    green("pass")
846                } else {
847                    yellow("warn")
848                },
849                o.command
850            );
851            if !o.ok() {
852                let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
853            }
854        }
855        if let Some(c) = &state.pre_gate_commit {
856            let _ = writeln!(s, "  committed mechanical fixes @ {}", short(c));
857        }
858    }
859
860    // `state.gate.is_empty()` alone cannot tell "never ran" apart from "ran
861    // with nothing configured" — see `RunState::gate_status`'s own doc — so
862    // this reads the accessor rather than the raw list.
863    match state.gate_status() {
864        GateStatus::NotRun => {}
865        GateStatus::PassedWithNoCommands => {
866            let _ = writeln!(s, "\n{}", bold("gate"));
867            let _ = writeln!(s, "  {}  no gate commands configured", green("pass"));
868        }
869        GateStatus::Passed | GateStatus::Failed => {
870            let _ = writeln!(s, "\n{}", bold("gate"));
871            for o in &state.gate {
872                let _ = writeln!(
873                    s,
874                    "  {}  {}",
875                    if o.ok() { green("pass") } else { red("FAIL") },
876                    o.command
877                );
878                if !o.ok() {
879                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
880                }
881            }
882        }
883    }
884
885    if let Some(m) = &state.merge {
886        let _ = writeln!(s, "\n{}", bold("merge"));
887        if m.mode == MergeMode::None {
888            // `ok: true` here means "magi did nothing, as configured", not
889            // "landed" — a green `ok` next to a shell command reads as done,
890            // and the branch is still sitting unmerged.
891            let _ = writeln!(
892                s,
893                "  mode None  {}",
894                cyan("not landed — nothing to do by design")
895            );
896            if let Some(w) = state.winner() {
897                let _ = writeln!(
898                    s,
899                    "  branch {} still exists, unmerged into {}",
900                    w.branch, state.base_branch
901                );
902            }
903            // The squash caveat only applies to that one style: `--no-ff` and
904            // `--ff-only` never inherit a candidate's placeholder subject,
905            // since neither ever discards the pull request body `message`
906            // that `manual_merge_command` (graph.rs) already puts on the
907            // squash commit's `-m`.
908            let _ = writeln!(
909                s,
910                "  rebase onto {} before merging by hand{}",
911                state.base_branch,
912                if state.config.merge.style == MergeStyle::Squash {
913                    ", and pass an explicit commit message — a squash merge \
914                     otherwise inherits the candidate's placeholder subject"
915                } else {
916                    ""
917                }
918            );
919            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
920        } else {
921            let _ = writeln!(
922                s,
923                "  mode {:?}  {}\n  {}",
924                m.mode,
925                if m.ok {
926                    green("ok")
927                } else {
928                    yellow("not merged")
929                },
930                m.detail.lines().next().unwrap_or("")
931            );
932        }
933    }
934
935    if let Some(b) = &state.release_bump {
936        let _ = writeln!(s, "\n{}", bold("release bump"));
937        if let Some(v) = &b.version {
938            let _ = writeln!(s, "  version v{v}");
939        }
940        if let Some(url) = &b.pr_url {
941            let _ = writeln!(s, "  pr {url}");
942        }
943        let _ = writeln!(
944            s,
945            "  automerge {}",
946            if b.automerge_enabled {
947                green("enabled")
948            } else if b.merged_directly {
949                green("not needed (CI was already green; merged directly)")
950            } else if b.problem.is_some() {
951                bold(&red("FAILED"))
952            } else {
953                dim("not enabled")
954            }
955        );
956        if let Some(p) = &b.problem {
957            let _ = writeln!(s, "  {}", p.lines().next().unwrap_or(""));
958        }
959        if let Some(a) = &b.action_required {
960            let _ = writeln!(s, "  {} {a}", bold(&yellow("action required:")));
961        }
962    }
963
964    if !state.leaks.is_empty() {
965        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
966        for l in &state.leaks {
967            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
968        }
969    }
970
971    if let Some(w) = state.winner()
972        && !w.folded
973    {
974        let _ = writeln!(
975            s,
976            "\n{} {}\n  branch {}",
977            bold("winner worktree"),
978            w.worktree.display(),
979            w.branch
980        );
981    }
982    s.push_str(&jobs_section(state));
983    s
984}
985
986/// The seats currently mid-answer, for `magi show` and the raw report route.
987///
988/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
989/// run` / `magi review`'s own `execute()`, and by then this process has
990/// nothing left in flight to report; the TUI does not track daemon liveness
991/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
992/// the web UI's raw-report route — needs this, and both already know how to
993/// ask whether a daemon is currently driving it.
994///
995/// `live` is [`RunState::liveness`]: whether a daemon's heartbeat currently
996/// names this run, or — absent that claim — whether `driver_pid` still
997/// answers alive. An [`ActiveSeat`](crate::run::ActiveSeat) left behind by a
998/// killed process is not lied about as running just because nobody has
999/// cleared it from disk yet — see that type's own docs for why an entry
1000/// alone is not proof of anything.
1001pub fn active_seats(state: &RunState, live: Liveness) -> String {
1002    if state.active.is_empty() {
1003        return String::new();
1004    }
1005    let mut s = String::new();
1006    let _ = writeln!(s, "\n{}", bold("running now"));
1007    let now = jiff::Timestamp::now();
1008    match live {
1009        Liveness::Live => {}
1010        Liveness::Dead => {
1011            let _ = writeln!(
1012                s,
1013                "  {}",
1014                yellow(
1015                    "no live daemon claims this run right now — likely left behind by a killed process"
1016                )
1017            );
1018        }
1019        Liveness::Unknown => {
1020            // `active_all_overrun` is never treated as proof of death here —
1021            // only ever mentioned alongside "could not confirm", since a
1022            // seat legitimately overrunning its budget while the process
1023            // driving it tears the attempt down looks identical on disk (see
1024            // `RunState::active_all_overrun`'s own doc).
1025            let overrun = if state.active_all_overrun(now) {
1026                " — every active seat has already run past its own timeout budget"
1027            } else {
1028                ""
1029            };
1030            let _ = writeln!(
1031                s,
1032                "  {}",
1033                yellow(&format!(
1034                    "whether a process is still driving this run could not be confirmed{overrun}"
1035                ))
1036            );
1037        }
1038    }
1039    for (seat, a) in state.seats_active() {
1040        let retry = if a.attempt > 0 {
1041            format!(" retry {}", a.attempt)
1042        } else {
1043            String::new()
1044        };
1045        let _ = writeln!(
1046            s,
1047            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
1048            seat,
1049            a.node,
1050            a.elapsed_secs(now),
1051            a.remaining_secs(now),
1052            a.timeout_secs
1053        );
1054    }
1055    for (task, a) in state.tasks_active() {
1056        let retry = if a.attempt > 0 {
1057            format!(" retry {}", a.attempt)
1058        } else {
1059            String::new()
1060        };
1061        let progress = match (a.index, a.total) {
1062            (Some(i), Some(t)) => format!(" ({i}/{t})"),
1063            _ => String::new(),
1064        };
1065        let _ = writeln!(
1066            s,
1067            "  {:<12} {:<12}{retry}{progress}  {}s elapsed, {}s left of {}s",
1068            task,
1069            a.node,
1070            a.elapsed_secs(now),
1071            a.remaining_secs(now),
1072            a.timeout_secs
1073        );
1074        if let Some(command) = &a.command {
1075            let _ = writeln!(s, "               {command}");
1076        }
1077    }
1078    s
1079}
1080
1081/// Aggregate tables, for `magi stats`.
1082pub fn stats(stats: &Stats) -> String {
1083    let t = &stats.totals;
1084    let mut s = String::new();
1085    let _ = writeln!(s, "{}", bold("runs"));
1086    let _ = writeln!(
1087        s,
1088        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1089        t.runs,
1090        t.merged,
1091        t.ready,
1092        t.blocked,
1093        t.failed,
1094        t.completion_rate()
1095    );
1096    if t.tallied > 0 {
1097        let _ = writeln!(
1098            s,
1099            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1100             {} of those changed a mind, {} converged to unanimous",
1101            t.tallied,
1102            t.split,
1103            t.split_rate(),
1104            t.deliberated,
1105            t.minds_changed,
1106            t.converged
1107        );
1108    }
1109
1110    if !stats.agents.is_empty() {
1111        let _ = writeln!(
1112            s,
1113            "\n{}",
1114            bold("implementation (relative, on this workload)")
1115        );
1116        let _ = writeln!(
1117            s,
1118            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
1119            "agent", "won", "entered", "rate", "empty"
1120        );
1121        for a in &stats.agents {
1122            let _ = writeln!(
1123                s,
1124                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
1125                a.agent,
1126                a.wins,
1127                a.entered,
1128                a.win_rate(),
1129                a.empty
1130            );
1131        }
1132    }
1133
1134    if !stats.reviewers.is_empty() {
1135        let _ = writeln!(s, "\n{}", bold("review"));
1136        let _ = writeln!(
1137            s,
1138            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1139            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1140        );
1141        for r in &stats.reviewers {
1142            let _ = writeln!(
1143                s,
1144                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1145                r.agent,
1146                r.rounds,
1147                r.submitted,
1148                r.adopted_per_round(),
1149                r.precision(),
1150                r.unique_rate(),
1151                r.timeout_rate()
1152            );
1153        }
1154    }
1155
1156    if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1157        let _ = writeln!(s, "\n{}", bold("verification"));
1158        let _ = writeln!(
1159            s,
1160            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
1161             review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1162            stats.e2e.rounds,
1163            stats.e2e.failures,
1164            stats.e2e.sole_detections,
1165            stats.e2e.sole_rate(),
1166            stats.e2e.deferred
1167        );
1168    }
1169    s
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175    use crate::config::Config;
1176    use crate::run::{
1177        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1178        Tally,
1179    };
1180    use std::collections::BTreeMap;
1181    use std::path::PathBuf;
1182    use std::sync::{Mutex, MutexGuard};
1183
1184    /// `COLOR` is process-global, so these tests cannot run concurrently.
1185    static SERIAL: Mutex<()> = Mutex::new(());
1186
1187    fn plain() -> MutexGuard<'static, ()> {
1188        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1189        set_color(false);
1190        guard
1191    }
1192
1193    fn state() -> RunState {
1194        // `run()` prints `state.dir()`, which reads the process-global home;
1195        // pinning it here keeps this test off the operator's real one. The
1196        // directory itself is never read, only its path printed, so nothing
1197        // needs to create or clean it up.
1198        crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
1199        let mut s = RunState::new(
1200            PathBuf::from("/repo"),
1201            "main".to_owned(),
1202            "abcdef1234".to_owned(),
1203            "add retries to the uploader".to_owned(),
1204            Config::default(),
1205        );
1206        s.candidates = vec![Candidate {
1207            index: 0,
1208            label: 'A',
1209            agent: "opus".to_owned(),
1210            branch: "magi/x/A".to_owned(),
1211            worktree: PathBuf::from("/wt/A"),
1212            summary: String::new(),
1213            stat: String::new(),
1214            files: 3,
1215            commits: 2,
1216            empty: false,
1217            failed: None,
1218            verified_noop: None,
1219            duration_ms: 42_000,
1220            folded: false,
1221        }];
1222        s.tally = Some(Tally {
1223            first_choice: BTreeMap::from([('A', 3)]),
1224            borda: BTreeMap::new(),
1225            winner: 'A',
1226            rankings: 3,
1227            unanimous_initial: true,
1228            deliberated: false,
1229            changed_votes: 0,
1230            unanimous_final: true,
1231            tie_break: None,
1232            judges: 3,
1233            present: 3,
1234            quorum: 2,
1235            met_quorum: true,
1236            uncontested: None,
1237        });
1238        s
1239    }
1240
1241    #[test]
1242    fn run_report_names_the_winner_and_its_author() {
1243        let _guard = plain();
1244        let text = run(&state());
1245        assert!(text.contains("<- winner"), "{text}");
1246        assert!(text.contains("opus"));
1247        assert!(text.contains("3 files, 2 commits"));
1248        assert!(text.contains("winner        A"));
1249        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1250    }
1251
1252    #[test]
1253    fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1254        // Recorded on every run (`prompt::implement`'s `## SUMMARY`), but
1255        // `run()` used to never print it at all — silent even when the
1256        // summary was the one place an implementer explained itself (e.g.
1257        // an investigation task's findings), and readable only by opening
1258        // `run.json` by hand.
1259        let _guard = plain();
1260        let mut s = state();
1261        s.candidates[0].summary =
1262            "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1263                .to_owned();
1264        let text = run(&s);
1265        assert!(
1266            text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1267            "{text}"
1268        );
1269    }
1270
1271    #[test]
1272    fn a_verified_noop_run_does_not_read_as_a_failure() {
1273        // Same spirit as `a_mode_none_merge_does_not_read_as_landed`: a run
1274        // that settled without landing anything must not be misreadable as
1275        // the ordinary failure it is not.
1276        let _guard = plain();
1277        let mut s = state();
1278        s.status = RunStatus::VerifiedNoop;
1279        s.tally = None;
1280        s.candidates = vec![Candidate {
1281            index: 0,
1282            label: 'A',
1283            agent: "opus".to_owned(),
1284            branch: "magi/x/A".to_owned(),
1285            worktree: PathBuf::from("/wt/A"),
1286            summary: String::new(),
1287            stat: String::new(),
1288            files: 0,
1289            commits: 0,
1290            empty: true,
1291            failed: None,
1292            verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1293            duration_ms: 9_000,
1294            folded: false,
1295        }];
1296        let text = run(&s);
1297        assert!(
1298            text.contains("agent-verified no-op"),
1299            "the status and the candidate flag must both say so: {text}"
1300        );
1301        assert!(
1302            text.contains("already fixed by b32cfc4"),
1303            "the evidence itself must be readable, not just the verdict: {text}"
1304        );
1305        assert!(
1306            !text.to_lowercase().contains("failed"),
1307            "a verified no-op must never read as the failure it is not: {text}"
1308        );
1309    }
1310
1311    #[test]
1312    fn colour_is_emitted_only_when_enabled() {
1313        let _guard = plain();
1314        set_color(true);
1315        let coloured = run(&state());
1316        set_color(false);
1317        let plain = run(&state());
1318        assert!(coloured.contains('\x1b'));
1319        assert!(!plain.contains('\x1b'));
1320        assert!(coloured.len() > plain.len());
1321    }
1322
1323    #[test]
1324    fn list_line_is_single_line() {
1325        let _guard = plain();
1326        let l = line(&state());
1327        assert_eq!(l.lines().count(), 1);
1328        assert!(l.contains("add retries"));
1329        assert!(l.contains("win A (opus)"));
1330    }
1331
1332    #[test]
1333    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1334        let _guard = plain();
1335        let mut s = state();
1336        s.tally = Some(Tally {
1337            first_choice: BTreeMap::from([('A', 0)]),
1338            borda: BTreeMap::new(),
1339            winner: 'A',
1340            rankings: 0,
1341            unanimous_initial: false,
1342            deliberated: false,
1343            changed_votes: 0,
1344            unanimous_final: false,
1345            tie_break: None,
1346            judges: 0,
1347            present: 0,
1348            quorum: 0,
1349            met_quorum: true,
1350            uncontested: Some(
1351                "only candidate A produced a usable change; no panel was asked".to_owned(),
1352            ),
1353        });
1354        let text = run(&s);
1355        assert!(
1356            !text.contains("0/3"),
1357            "no panel sat, so the judges line must not read as one that collapsed: {text}"
1358        );
1359        assert!(!text.contains("no usable ranking"), "{text}");
1360        assert!(!text.contains("still split"), "{text}");
1361        assert!(!text.contains("BELOW QUORUM"), "{text}");
1362        assert!(
1363            text.contains("not needed"),
1364            "the report must say judging was skipped, not silent: {text}"
1365        );
1366        assert!(text.contains("winner        A"));
1367    }
1368
1369    #[test]
1370    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1371        let _guard = plain();
1372        let mut s = state();
1373        s.tally = Some(Tally {
1374            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1375            borda: BTreeMap::new(),
1376            winner: 'A',
1377            rankings: 1,
1378            unanimous_initial: false,
1379            deliberated: false,
1380            changed_votes: 0,
1381            unanimous_final: false,
1382            tie_break: None,
1383            judges: 3,
1384            present: 1,
1385            quorum: 2,
1386            met_quorum: false,
1387            uncontested: None,
1388        });
1389        let text = run(&s);
1390        assert!(text.contains("1/3"), "{text}");
1391        assert!(
1392            text.contains("BELOW QUORUM"),
1393            "a real collapse must still be flagged: {text}"
1394        );
1395        assert!(
1396            !text.contains("not needed"),
1397            "a collapsed panel must not be described as one that was never asked: {text}"
1398        );
1399    }
1400
1401    #[test]
1402    fn a_mode_none_merge_does_not_read_as_landed() {
1403        let _guard = plain();
1404        let mut s = state();
1405        s.merge = Some(MergeOutcome {
1406            mode: crate::config::MergeMode::None,
1407            ok: true,
1408            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1409        });
1410        let text = run(&s);
1411        assert!(
1412            !text.contains("  ok"),
1413            "mode none must not be shown as a landed merge: {text}"
1414        );
1415        assert!(text.contains("not landed"), "{text}");
1416        assert!(
1417            text.contains("branch magi/x/A"),
1418            "the report must say what's left behind: {text}"
1419        );
1420        assert!(
1421            text.contains("rebase"),
1422            "the report must point at the hand-landing steps: {text}"
1423        );
1424        assert!(
1425            !text.contains("placeholder subject"),
1426            "the default merge style is `merge`, which never inherits a \
1427             placeholder subject, so the squash caveat must not appear: {text}"
1428        );
1429    }
1430
1431    #[test]
1432    fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1433        let _guard = plain();
1434        let mut s = state();
1435        s.config.merge.style = MergeStyle::Squash;
1436        s.merge = Some(MergeOutcome {
1437            mode: crate::config::MergeMode::None,
1438            ok: true,
1439            detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1440                      retries\""
1441                .to_owned(),
1442        });
1443        let text = run(&s);
1444        assert!(
1445            text.contains("placeholder subject"),
1446            "a squash-style manual merge must warn about the missing message: {text}"
1447        );
1448        assert!(text.contains("--squash"), "{text}");
1449    }
1450
1451    #[test]
1452    fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1453        let _guard = plain();
1454        let mut s = state();
1455        s.status = RunStatus::Ready;
1456        s.merge = Some(MergeOutcome {
1457            mode: crate::config::MergeMode::None,
1458            ok: true,
1459            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1460        });
1461
1462        let list = line(&s);
1463        assert!(
1464            !list.contains(" ready "),
1465            "a mode-none run must not read as a plain ready in `magi list`: {list}"
1466        );
1467        assert!(list.contains("no-op by design"), "{list}");
1468
1469        let full = run(&s);
1470        assert!(
1471            !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1472            "the header line of `magi show` must not say plain ready either: {full}"
1473        );
1474        assert!(full.contains("no-op by design"), "{full}");
1475    }
1476
1477    #[test]
1478    fn an_ordinary_ready_run_still_reads_as_ready() {
1479        let _guard = plain();
1480        let mut s = state();
1481        s.status = RunStatus::Ready;
1482        // A PR closed without merging also ends at `Ready` (see `land.rs`),
1483        // and unlike the honest mode-none no-op it must keep reading as a
1484        // plain `ready` — the label exists to flag design, not every non-merge.
1485        s.merge = Some(MergeOutcome {
1486            mode: crate::config::MergeMode::Pr,
1487            ok: false,
1488            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1489        });
1490
1491        let list = line(&s);
1492        assert!(list.contains("ready"), "{list}");
1493        assert!(!list.contains("no-op by design"), "{list}");
1494    }
1495
1496    #[test]
1497    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1498        let _guard = plain();
1499        let mut s = state();
1500        s.tally = Some(Tally {
1501            first_choice: BTreeMap::from([('A', 0)]),
1502            borda: BTreeMap::new(),
1503            winner: 'A',
1504            rankings: 0,
1505            unanimous_initial: false,
1506            deliberated: false,
1507            changed_votes: 0,
1508            unanimous_final: false,
1509            tie_break: None,
1510            judges: 0,
1511            present: 0,
1512            quorum: 0,
1513            met_quorum: true,
1514            uncontested: Some("only candidate A produced a usable change".to_owned()),
1515        });
1516        let l = line(&s);
1517        assert!(
1518            !l.contains("judges") && !l.contains("quorum"),
1519            "an uncontested run must not carry the same badge a short panel gets: {l}"
1520        );
1521    }
1522
1523    #[test]
1524    fn long_instructions_are_elided() {
1525        let _guard = plain();
1526        let mut s = state();
1527        s.instruction = "x".repeat(200);
1528        assert!(line(&s).contains('…'));
1529    }
1530
1531    #[test]
1532    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1533        let _guard = plain();
1534        let mut lost = state();
1535        lost.reviews = vec![ReviewRound {
1536            round: 1,
1537            head: "abc1234".to_owned(),
1538            verified_head: None,
1539            verified_at: None,
1540            reviews: Vec::new(),
1541            e2e: Vec::new(),
1542            verify_retried: false,
1543            e2e_deferred: false,
1544            e2e_defer_reason: None,
1545            fix: Some(FixRecord {
1546                agent: "opus".to_owned(),
1547                addressed: Vec::new(),
1548                rejected: Vec::new(),
1549                notes: String::new(),
1550                committed: true,
1551                failed: Some("timed out".to_owned()),
1552                duration_ms: 0,
1553                continuation: None,
1554            }),
1555            blocking: 3,
1556            answered: 0,
1557            expected: 0,
1558            clean: false,
1559            progressed: false,
1560            vote_split: false,
1561            reconsideration: Vec::new(),
1562            verdict: None,
1563        }];
1564        let text = run(&lost);
1565        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1566        assert!(
1567            !text.contains("0 addressed"),
1568            "a lost report must never read as `0 addressed`: {text}"
1569        );
1570
1571        let mut rejected_all = state();
1572        rejected_all.reviews = vec![ReviewRound {
1573            round: 1,
1574            head: "abc1234".to_owned(),
1575            verified_head: None,
1576            verified_at: None,
1577            reviews: Vec::new(),
1578            e2e: Vec::new(),
1579            verify_retried: false,
1580            e2e_deferred: false,
1581            e2e_defer_reason: None,
1582            fix: Some(FixRecord {
1583                agent: "opus".to_owned(),
1584                addressed: Vec::new(),
1585                rejected: Vec::new(),
1586                notes: String::new(),
1587                committed: true,
1588                failed: None,
1589                duration_ms: 0,
1590                continuation: None,
1591            }),
1592            blocking: 3,
1593            answered: 0,
1594            expected: 0,
1595            clean: false,
1596            progressed: false,
1597            vote_split: false,
1598            reconsideration: Vec::new(),
1599            verdict: None,
1600        }];
1601        let text2 = run(&rejected_all);
1602        assert!(
1603            text2.contains("0 addressed / 0 rejected"),
1604            "a round the fixer actually reported on keeps the count: {text2}"
1605        );
1606    }
1607
1608    #[test]
1609    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1610        use crate::run::ReviewRevoteRecord;
1611        use crate::verdict::ReviewVote;
1612
1613        let _guard = plain();
1614        let mut s = state();
1615        s.reviews = vec![ReviewRound {
1616            round: 1,
1617            head: "abc1234".to_owned(),
1618            verified_head: None,
1619            verified_at: None,
1620            reviews: vec![
1621                ReviewRecord {
1622                    attempts: 0,
1623                    reviewer: 1,
1624                    agent: "alpha".to_owned(),
1625                    summary: String::new(),
1626                    findings: Vec::new(),
1627                    vote: Some(ReviewVote::Approve),
1628                    failed: None,
1629                    duration_ms: 0,
1630                },
1631                ReviewRecord {
1632                    attempts: 0,
1633                    reviewer: 2,
1634                    agent: "beta".to_owned(),
1635                    summary: String::new(),
1636                    findings: Vec::new(),
1637                    vote: Some(ReviewVote::Reject),
1638                    failed: None,
1639                    duration_ms: 0,
1640                },
1641            ],
1642            e2e: Vec::new(),
1643            verify_retried: false,
1644            e2e_deferred: false,
1645            e2e_defer_reason: None,
1646            fix: None,
1647            blocking: 0,
1648            answered: 2,
1649            expected: 2,
1650            clean: false,
1651            progressed: false,
1652            vote_split: true,
1653            reconsideration: vec![ReviewRevoteRecord {
1654                reviewer: 2,
1655                agent: "beta".to_owned(),
1656                vote: Some(ReviewVote::ApproveWithFindings),
1657                reason: "the other seat's read holds up".to_owned(),
1658                failed: None,
1659            }],
1660            verdict: Some(ReviewVote::ApproveWithFindings),
1661        }];
1662        let text = run(&s);
1663        assert!(text.contains("review-1 vote"), "{text}");
1664        assert!(text.contains("review-2 vote"), "{text}");
1665        assert!(text.contains("panel split"), "{text}");
1666        assert!(text.contains("reconsideration"), "{text}");
1667        assert!(text.contains("the other seat's read holds up"), "{text}");
1668    }
1669
1670    #[test]
1671    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1672        // Two independent facts share this one line, and each arrived from a
1673        // different change: a seat that never answered, and a fixer whose
1674        // adoption report was lost. Rendering either must not shadow the
1675        // other, and neither may collapse into the plain `clean`/`open`
1676        // pair the line used to carry.
1677        let _guard = plain();
1678        let mut s = state();
1679        s.reviews = vec![ReviewRound {
1680            round: 1,
1681            head: "abc1234".to_owned(),
1682            verified_head: None,
1683            verified_at: None,
1684            reviews: vec![
1685                ReviewRecord {
1686                    attempts: 0,
1687                    reviewer: 1,
1688                    agent: "alpha".to_owned(),
1689                    summary: String::new(),
1690                    findings: Vec::new(),
1691                    vote: None,
1692                    failed: None,
1693                    duration_ms: 0,
1694                },
1695                ReviewRecord {
1696                    attempts: 0,
1697                    reviewer: 2,
1698                    agent: "beta".to_owned(),
1699                    summary: String::new(),
1700                    findings: Vec::new(),
1701                    vote: None,
1702                    failed: Some("agent timed out".to_owned()),
1703                    duration_ms: 0,
1704                },
1705            ],
1706            e2e: Vec::new(),
1707            verify_retried: false,
1708            e2e_deferred: false,
1709            e2e_defer_reason: None,
1710            fix: Some(FixRecord {
1711                agent: "opus".to_owned(),
1712                addressed: Vec::new(),
1713                rejected: Vec::new(),
1714                notes: String::new(),
1715                committed: true,
1716                failed: Some("timed out".to_owned()),
1717                duration_ms: 0,
1718                continuation: None,
1719            }),
1720            blocking: 0,
1721            answered: 1,
1722            expected: 2,
1723            clean: false,
1724            progressed: true,
1725            vote_split: false,
1726            reconsideration: Vec::new(),
1727            verdict: None,
1728        }];
1729        let text = run(&s);
1730        assert!(text.contains("incomplete"), "{text}");
1731        assert!(text.contains("1/2 reviewers answered"), "{text}");
1732        assert!(text.contains("review-2: agent timed out"), "{text}");
1733        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1734        assert!(
1735            !text.contains("clean"),
1736            "a round missing half its panel must never render as clean: {text}"
1737        );
1738    }
1739
1740    #[test]
1741    fn a_build_failure_is_not_reported_as_a_test_failure() {
1742        let _guard = plain();
1743        let mut s = state();
1744        s.reviews = vec![ReviewRound {
1745            round: 1,
1746            head: "abc1234".to_owned(),
1747            verified_head: None,
1748            verified_at: None,
1749            reviews: Vec::new(),
1750            e2e: vec![CommandOutcome {
1751                command: "cargo test".to_owned(),
1752                code: Some(1),
1753                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1754                duration_ms: 100,
1755                resource_blocked: false,
1756            }],
1757            verify_retried: true,
1758            e2e_deferred: false,
1759            e2e_defer_reason: None,
1760            fix: None,
1761            blocking: 0,
1762            answered: 0,
1763            expected: 0,
1764            clean: false,
1765            progressed: false,
1766            vote_split: false,
1767            reconsideration: Vec::new(),
1768            verdict: None,
1769        }];
1770        let text = run(&s);
1771        assert!(text.contains("could not run"), "{text}");
1772        assert!(text.contains("retried once"), "{text}");
1773        assert!(!text.contains("e2e RED"), "{text}");
1774    }
1775
1776    #[test]
1777    fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
1778        let _guard = plain();
1779        let mut s = state();
1780        s.reviews = vec![ReviewRound {
1781            round: 1,
1782            head: "abc1234".to_owned(),
1783            verified_head: None,
1784            verified_at: None,
1785            reviews: Vec::new(),
1786            e2e: vec![CommandOutcome {
1787                command: "(waiting for the shared build cache)".to_owned(),
1788                code: None,
1789                output_tail: "held by run x node e2e seat e2e".to_owned(),
1790                duration_ms: 100,
1791                resource_blocked: true,
1792            }],
1793            verify_retried: false,
1794            e2e_deferred: false,
1795            e2e_defer_reason: None,
1796            fix: None,
1797            blocking: 0,
1798            answered: 0,
1799            expected: 0,
1800            clean: false,
1801            progressed: false,
1802            vote_split: false,
1803            reconsideration: Vec::new(),
1804            verdict: None,
1805        }];
1806        let text = run(&s);
1807        assert!(text.contains("shared build cache unavailable"), "{text}");
1808        assert!(!text.contains("e2e RED"), "{text}");
1809        assert!(!text.contains("build/link failure"), "{text}");
1810    }
1811
1812    #[test]
1813    fn a_round_with_more_than_one_e2e_command_names_each_one() {
1814        // The aggregate `e2e RED` label says the round's overall verdict,
1815        // never which of several `verify.e2e` commands actually failed —
1816        // `magi show` must list each command by name, the same way it
1817        // already does for `gate`.
1818        let _guard = plain();
1819        let mut s = state();
1820        s.reviews = vec![ReviewRound {
1821            round: 1,
1822            head: "abc1234".to_owned(),
1823            verified_head: Some("abc1234".to_owned()),
1824            verified_at: Some(jiff::Timestamp::now()),
1825            reviews: Vec::new(),
1826            e2e: vec![
1827                CommandOutcome {
1828                    command: "cargo test --locked --all-targets".to_owned(),
1829                    code: Some(0),
1830                    output_tail: String::new(),
1831                    duration_ms: 0,
1832                    resource_blocked: false,
1833                },
1834                CommandOutcome {
1835                    command: "cargo make check".to_owned(),
1836                    code: Some(1),
1837                    output_tail: "clippy: unused import".to_owned(),
1838                    duration_ms: 0,
1839                    resource_blocked: false,
1840                },
1841            ],
1842            verify_retried: false,
1843            e2e_deferred: false,
1844            e2e_defer_reason: None,
1845            fix: None,
1846            blocking: 0,
1847            answered: 0,
1848            expected: 0,
1849            clean: false,
1850            progressed: false,
1851            vote_split: false,
1852            reconsideration: Vec::new(),
1853            verdict: None,
1854        }];
1855        let text = run(&s);
1856        assert!(text.contains("cargo test --locked --all-targets"), "{text}");
1857        assert!(text.contains("cargo make check"), "{text}");
1858        assert!(text.contains("clippy: unused import"), "{text}");
1859    }
1860
1861    #[test]
1862    fn a_declined_finding_shows_its_reason() {
1863        use crate::verdict::{Finding, Rejection, Severity};
1864
1865        let _guard = plain();
1866        let mut s = state();
1867        s.status = RunStatus::Ready;
1868        s.reviews = vec![ReviewRound {
1869            round: 1,
1870            head: "deadbee".to_owned(),
1871            verified_head: None,
1872            verified_at: None,
1873            reviews: vec![ReviewRecord {
1874                attempts: 0,
1875                reviewer: 1,
1876                agent: "alpha".to_owned(),
1877                summary: String::new(),
1878                findings: vec![Finding {
1879                    id: "R1-1-1".to_owned(),
1880                    severity: Severity::Major,
1881                    file: None,
1882                    line: None,
1883                    title: "still open".to_owned(),
1884                    detail: String::new(),
1885                }],
1886                vote: None,
1887                failed: None,
1888                duration_ms: 0,
1889            }],
1890            e2e: vec![CommandOutcome {
1891                command: "cargo test".to_owned(),
1892                code: Some(0),
1893                output_tail: String::new(),
1894                duration_ms: 0,
1895                resource_blocked: false,
1896            }],
1897            verify_retried: false,
1898            e2e_deferred: false,
1899            e2e_defer_reason: None,
1900            fix: Some(FixRecord {
1901                agent: "alpha".to_owned(),
1902                addressed: Vec::new(),
1903                rejected: vec![Rejection {
1904                    id: "R1-1-2".to_owned(),
1905                    why: "cannot be triggered from any caller".to_owned(),
1906                }],
1907                notes: String::new(),
1908                committed: true,
1909                failed: None,
1910                duration_ms: 0,
1911                continuation: None,
1912            }),
1913            blocking: 1,
1914            answered: 1,
1915            expected: 1,
1916            clean: false,
1917            progressed: true,
1918            vote_split: false,
1919            reconsideration: Vec::new(),
1920            verdict: None,
1921        }];
1922
1923        let text = run(&s);
1924        assert!(text.contains("R1-1-2"), "{text}");
1925        assert!(text.contains("cannot be triggered"), "{text}");
1926        assert!(text.contains("still open"), "{text}");
1927        assert!(
1928            text.contains("handed off"),
1929            "a mergeable run with an open round must say so: {text}"
1930        );
1931    }
1932
1933    #[test]
1934    fn a_failing_gate_command_shows_its_output() {
1935        let _guard = plain();
1936        let mut s = state();
1937        s.status = RunStatus::Blocked;
1938        s.gate = vec![CommandOutcome {
1939            command: "cargo make check".to_owned(),
1940            code: Some(101),
1941            output_tail: "error[E0308]: mismatched types".to_owned(),
1942            duration_ms: 0,
1943            resource_blocked: false,
1944        }];
1945        s.gate_ran = true;
1946
1947        let text = run(&s);
1948        assert!(text.contains("mismatched types"), "{text}");
1949    }
1950
1951    #[test]
1952    fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
1953        let _guard = plain();
1954        let mut s = state();
1955        s.status = RunStatus::Ready;
1956        s.gate_ran = true;
1957        assert!(s.gate.is_empty());
1958
1959        let text = run(&s);
1960        assert!(
1961            text.contains("gate") && text.contains("no gate commands configured"),
1962            "a run gated on nothing must say so, not read as if the gate never ran: {text}"
1963        );
1964    }
1965
1966    #[test]
1967    fn a_gate_that_has_not_run_yet_shows_nothing() {
1968        let _guard = plain();
1969        let s = state();
1970        assert!(!s.gate_ran);
1971        assert!(s.gate.is_empty());
1972
1973        let text = run(&s);
1974        assert!(
1975            !text.contains("no gate commands configured"),
1976            "an unattempted gate must not be shown as a pass: {text}"
1977        );
1978    }
1979
1980    #[test]
1981    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1982        let _guard = plain();
1983        let mut s = state();
1984        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1985        let text = active_seats(&s, Liveness::Live);
1986        assert!(text.contains("running now"));
1987        assert!(text.contains("judge-2"));
1988        assert!(text.contains("judge"));
1989        assert!(!text.contains("no live daemon"), "{text}");
1990        assert!(!text.contains("could not be confirmed"), "{text}");
1991    }
1992
1993    #[test]
1994    fn active_seats_flags_a_leftover_from_a_dead_process() {
1995        let _guard = plain();
1996        let mut s = state();
1997        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1998        let text = active_seats(&s, Liveness::Dead);
1999        assert!(
2000            text.contains("no live daemon"),
2001            "a stale entry must not read as running: {text}"
2002        );
2003    }
2004
2005    #[test]
2006    fn superseded_notice_names_the_run_that_replaced_it() {
2007        let _guard = plain();
2008        let text = superseded_notice(Some("bbbb"));
2009        assert!(text.contains("SUPERSEDED"), "{text}");
2010        assert!(text.contains("bbbb"), "{text}");
2011    }
2012
2013    #[test]
2014    fn superseded_notice_is_silent_for_the_latest_attempt() {
2015        let _guard = plain();
2016        assert_eq!(superseded_notice(None), "");
2017    }
2018
2019    #[test]
2020    fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2021        let _guard = plain();
2022        let mut s = state();
2023        s.status = RunStatus::Reviewing;
2024        let text = line_with_liveness(&s, Liveness::Dead);
2025        assert!(text.contains("STALE"), "{text}");
2026        assert!(text.contains("resume required"), "{text}");
2027        assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2028    }
2029
2030    /// A manual `magi run` / `magi review` claims no daemon, but its
2031    /// `driver_pid` still answers — this is `Unknown`, never `Dead`: see
2032    /// `RunState::liveness`'s own doc for why an unconfirmed process is never
2033    /// folded into "confirmed dead".
2034    #[test]
2035    fn active_seats_reports_uncertainty_without_claiming_death() {
2036        let _guard = plain();
2037        let mut s = state();
2038        s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2039        let text = active_seats(&s, Liveness::Unknown);
2040        assert!(
2041            text.contains("could not be confirmed"),
2042            "an unproven state must read as uncertain, not dead: {text}"
2043        );
2044        assert!(!text.contains("no live daemon"), "{text}");
2045    }
2046
2047    #[test]
2048    fn active_seats_is_empty_when_nothing_is_running() {
2049        let _guard = plain();
2050        assert_eq!(active_seats(&state(), Liveness::Live), "");
2051    }
2052
2053    /// A running `verify.e2e` / `verify.gate` task is shown alongside seats,
2054    /// with which command is currently running and how far through the list
2055    /// it is — the gap the addendum's second item names: a review-only run
2056    /// that spends minutes inside `cargo test` between reviewer answers and
2057    /// the fix's own commit had nothing at all to show for it before this.
2058    #[test]
2059    fn active_seats_shows_a_running_verify_task_and_its_command() {
2060        let _guard = plain();
2061        let mut s = state();
2062        s.task_command(
2063            "e2e",
2064            "verify",
2065            0,
2066            "cargo test",
2067            2,
2068            3,
2069            std::time::Duration::from_secs(600),
2070        );
2071        let text = active_seats(&s, Liveness::Live);
2072        assert!(text.contains("e2e"), "{text}");
2073        assert!(text.contains("(2/3)"), "{text}");
2074        assert!(text.contains("cargo test"), "{text}");
2075    }
2076
2077    #[test]
2078    fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2079        let _guard = plain();
2080        // The common case today (every backend but codex): silence, not a
2081        // clutter line repeated on every single `magi show`.
2082        assert!(!run(&state()).contains("background jobs"));
2083    }
2084
2085    #[test]
2086    fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2087        let _guard = plain();
2088        let mut s = state();
2089        s.config.agents.push(crate::config::AgentSpec {
2090            id: "codex-one".to_owned(),
2091            kind: crate::config::AgentKind::Codex,
2092            model: None,
2093            command: vec!["codex".to_owned()],
2094            extra_args: Vec::new(),
2095            env: BTreeMap::new(),
2096            prompt_delivery: None,
2097        });
2098        let text = run(&s);
2099        assert!(
2100            text.contains("background jobs"),
2101            "a run that could report this must not read the same as one that never could: \
2102             {text}"
2103        );
2104        assert!(text.contains("no completed command evidence yet"));
2105    }
2106
2107    #[test]
2108    fn recovered_running_and_unreadable_jobs_are_told_apart() {
2109        let _guard = plain();
2110        let mut s = state();
2111        s.jobs = vec![
2112            crate::run::JobRecord {
2113                node: "implement".to_owned(),
2114                round: None,
2115                seat: "impl-A".to_owned(),
2116                id: "item49".to_owned(),
2117                description: "cargo test --test graph_cached_gate".to_owned(),
2118                checked_at: jiff::Timestamp::now(),
2119                status: crate::run::JobStatus::Completed,
2120                exit_code: Some(0),
2121                result_summary: "test result: 2 passed; 0 failed".to_owned(),
2122                source: "codex".to_owned(),
2123            },
2124            crate::run::JobRecord {
2125                node: "fix".to_owned(),
2126                round: None,
2127                seat: "impl-A".to_owned(),
2128                id: "item52".to_owned(),
2129                description: "cargo test --test graph_split".to_owned(),
2130                checked_at: jiff::Timestamp::now(),
2131                status: crate::run::JobStatus::Failed,
2132                exit_code: Some(101),
2133                result_summary: "test result: 1 passed; 1 failed".to_owned(),
2134                source: "codex".to_owned(),
2135            },
2136            crate::run::JobRecord {
2137                node: "fix".to_owned(),
2138                round: None,
2139                seat: "impl-A".to_owned(),
2140                id: "item60".to_owned(),
2141                description: "cargo build".to_owned(),
2142                checked_at: jiff::Timestamp::now(),
2143                status: crate::run::JobStatus::Unknown,
2144                exit_code: None,
2145                result_summary: String::new(),
2146                source: "codex".to_owned(),
2147            },
2148        ];
2149        let text = run(&s);
2150        assert!(text.contains("background jobs"));
2151        assert!(text.contains("item49"));
2152        assert!(text.contains("item52"));
2153        assert!(text.contains("item60"));
2154        // The three states this run actually has evidence for must read
2155        // differently from one another — never collapsed into a single
2156        // "ran" or "did not run".
2157        assert!(text.contains("completed"));
2158        assert!(text.contains("failed"));
2159        assert!(text.contains("unknown"));
2160        // Coverage limit stated once, not fabricated per seat.
2161        assert!(text.contains("adapter coverage"));
2162    }
2163
2164    #[test]
2165    fn a_jobs_own_round_is_shown_when_known() {
2166        let _guard = plain();
2167        let mut s = state();
2168        s.jobs = vec![crate::run::JobRecord {
2169            node: "review".to_owned(),
2170            round: Some(2),
2171            seat: "review-1".to_owned(),
2172            id: "item9".to_owned(),
2173            description: "cargo test --test graph_cached_gate".to_owned(),
2174            checked_at: jiff::Timestamp::now(),
2175            status: crate::run::JobStatus::Completed,
2176            exit_code: Some(0),
2177            result_summary: "test result: 2 passed; 0 failed".to_owned(),
2178            source: "codex".to_owned(),
2179        }];
2180        let text = run(&s);
2181        assert!(
2182            text.contains("round 2"),
2183            "the round this seat's own command ran in must be visible, distinct from magi's \
2184             own recorded verify: {text}"
2185        );
2186    }
2187
2188    #[test]
2189    fn stats_table_renders_without_runs() {
2190        let _guard = plain();
2191        let text = stats(&Stats::default());
2192        assert!(text.contains("0 total"));
2193        assert!(!text.contains("implementation"));
2194    }
2195}