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