Skip to main content

magi/
report.rs

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