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.reviews.is_empty() {
490        let _ = writeln!(s, "\n{}", bold("review + verification"));
491        for r in &state.reviews {
492            let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
493            // A build/link failure is not a verdict on the patch (a shared
494            // `CARGO_TARGET_DIR` link race looks exactly like one), so it
495            // must not read the same as a real test failure. And a deferred
496            // round is not a passed one: `e2e.is_empty()` alone cannot tell
497            // "not configured" from "skipped on purpose" apart, which is
498            // exactly why `e2e_status` exists rather than reading `e2e`
499            // directly here.
500            let e2e = match r.e2e_status() {
501                E2eStatus::NotConfigured => dim("no e2e"),
502                E2eStatus::Deferred => yellow(&format!(
503                    "e2e deferred{}",
504                    r.e2e_defer_reason
505                        .as_deref()
506                        .map(|why| format!(" ({why})"))
507                        .unwrap_or_default()
508                )),
509                E2eStatus::Passed => green("e2e green"),
510                E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
511                    yellow("e2e could not run (build/link failure)")
512                }
513                E2eStatus::Failed => red("e2e RED"),
514                // Magi's own admission it could not get a command to run —
515                // never the same yellow/red a real attempt earns, since
516                // nothing here is evidence about the patch (see
517                // `E2eStatus::ResourceBlocked`'s own doc).
518                E2eStatus::ResourceBlocked => {
519                    yellow("e2e could not run (shared build cache unavailable)")
520                }
521            };
522            let e2e = if r.verify_retried {
523                format!("{e2e}, retried once")
524            } else {
525                e2e
526            };
527            // Three distinct facts, not two: a round can be *open* (blocking
528            // findings still standing), *incomplete* (a seat never answered,
529            // so what the round says is missing input) or genuinely clean.
530            let status = if r.incomplete() {
531                yellow("incomplete")
532            } else if r.clean {
533                green("clean")
534            } else {
535                yellow("open")
536            };
537            // A missing seat must stay visible even when `warn` policy let
538            // the round gate as clean: the reader should never have to take
539            // "clean" on faith when the panel wasn't full.
540            let panel = if r.incomplete() {
541                let missing: Vec<String> = r
542                    .reviews
543                    .iter()
544                    .filter_map(|x| {
545                        x.failed
546                            .as_ref()
547                            .map(|why| format!("review-{}: {why}", x.reviewer))
548                    })
549                    .collect();
550                format!(
551                    "  {}/{} reviewers answered ({})",
552                    r.answered,
553                    r.expected,
554                    missing.join(", ")
555                )
556            } else {
557                String::new()
558            };
559            // The verdict is the one thing this loop cannot derive from
560            // `blocking`/`e2e` alone: three seats can agree there is nothing
561            // blocking and still split on whether the patch is fine to
562            // proceed as-is, which is exactly the disagreement a vote exists
563            // to surface.
564            let verdict = r.verdict.map_or(String::new(), |v| {
565                format!(
566                    ", verdict {}{}",
567                    vote_tag(v),
568                    if r.vote_split { " (panel split)" } else { "" }
569                )
570            });
571            let _ = writeln!(
572                s,
573                "  round {}  {} @ {}{}{panel}  {raised} finding(s), {} blocking, {e2e}{verdict}{}",
574                r.round,
575                status,
576                short(&r.head),
577                r.verified_head.as_ref().map_or(String::new(), |head| {
578                    format!(
579                        " (verified @ {}{})",
580                        short(head),
581                        r.verified_at.map_or(String::new(), |t| format!(
582                            " on {}",
583                            t.to_zoned(jiff::tz::TimeZone::system())
584                                .strftime("%Y-%m-%d %H:%M:%S")
585                        ))
586                    )
587                }),
588                r.blocking,
589                r.fix.as_ref().map_or(String::new(), |f| {
590                    let tree = if r.progressed {
591                        green("changed")
592                    } else {
593                        yellow("unchanged")
594                    };
595                    let cont = f
596                        .continuation
597                        .as_ref()
598                        .map_or(String::new(), continuation_note);
599                    match &f.failed {
600                        // Never the same shape as "N addressed / M rejected": the
601                        // fixer's diff may well have landed (see the `fix` node's
602                        // own event), but whether it addressed anything is
603                        // unknown, not zero.
604                        Some(reason) => format!(
605                            "  fix: {}, tree {tree}{}{cont}",
606                            yellow(&format!("adoption report lost ({reason})")),
607                            if f.committed {
608                                String::new()
609                            } else {
610                                red(" (NO COMMIT)")
611                            }
612                        ),
613                        None => format!(
614                            "  fix: {} addressed / {} rejected, tree {tree}{}{cont}",
615                            f.addressed.len(),
616                            f.rejected.len(),
617                            if f.committed {
618                                String::new()
619                            } else {
620                                red(" (NO COMMIT)")
621                            }
622                        ),
623                    }
624                })
625            );
626            // The aggregate label above (`e2e`) says the round's overall
627            // verdict; it never named the commands themselves, so a round
628            // with more than one `verify.e2e` command left no way to tell
629            // which one actually failed or was blocked without opening
630            // `run.json` by hand — the same gap `gate`'s own per-command
631            // listing above already closes for the final gate.
632            for o in &r.e2e {
633                let label = if o.resource_blocked {
634                    yellow("blocked")
635                } else if o.ok() {
636                    green("pass")
637                } else {
638                    red("FAIL")
639                };
640                let _ = writeln!(s, "    {label}  {}", o.command);
641                if !o.ok() {
642                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
643                }
644            }
645            for rec in &r.reviews {
646                if let Some(vote) = rec.vote {
647                    let _ = writeln!(s, "      review-{} vote {}", rec.reviewer, vote_tag(vote));
648                }
649                for f in &rec.findings {
650                    let adopted = r
651                        .fix
652                        .as_ref()
653                        .is_some_and(|fix| fix.addressed.contains(&f.id));
654                    let _ = writeln!(
655                        s,
656                        "      {} [{:?}] {}{}",
657                        dim(&f.id),
658                        f.severity,
659                        f.title,
660                        if adopted {
661                            green("  fixed")
662                        } else {
663                            String::new()
664                        }
665                    );
666                }
667            }
668            if let Some(fix) = &r.fix {
669                for rej in &fix.rejected {
670                    let _ = writeln!(
671                        s,
672                        "      {} {}: {}",
673                        dim(&rej.id),
674                        yellow("declined"),
675                        rej.why
676                    );
677                }
678            }
679            // Reconsideration only ever has entries when the round's initial
680            // votes split — an empty list here means the panel agreed the
681            // first time, same as an empty `deliberation` for judges.
682            if !r.reconsideration.is_empty() {
683                let _ = writeln!(s, "      {}", dim("reconsideration:"));
684                for rv in &r.reconsideration {
685                    match rv.vote {
686                        Some(v) => {
687                            let _ = writeln!(
688                                s,
689                                "        review-{} -> {}  {}",
690                                rv.reviewer,
691                                vote_tag(v),
692                                rv.reason
693                            );
694                        }
695                        None => {
696                            let _ = writeln!(
697                                s,
698                                "        review-{} -> {}",
699                                rv.reviewer,
700                                red(&format!(
701                                    "no revote ({})",
702                                    rv.failed.as_deref().unwrap_or("unknown")
703                                ))
704                            );
705                        }
706                    }
707                }
708            }
709        }
710        if state.handed_off_with_open_findings() {
711            let _ = writeln!(
712                s,
713                "\n  {}",
714                yellow(&format!(
715                    "handed off with {} finding(s) still open — gate and e2e were green; \
716                     see above for what a person should still look at",
717                    state.open_findings().len()
718                ))
719            );
720        }
721    }
722
723    if !state.operator_fixes.is_empty() {
724        let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
725        for (i, req) in state.operator_fixes.iter().enumerate() {
726            let _ = writeln!(
727                s,
728                "  [{}] {} finding(s) at {}{}",
729                i + 1,
730                req.findings.len(),
731                req.requested_at
732                    .to_zoned(jiff::tz::TimeZone::system())
733                    .strftime("%Y-%m-%d %H:%M:%S"),
734                if req.stale {
735                    yellow("  stale head, --allow-stale used")
736                } else {
737                    String::new()
738                }
739            );
740            let _ = writeln!(s, "      reason: {}", req.reason);
741            for f in &req.findings {
742                let outcome = match &f.outcome {
743                    OperatorFixOutcome::Pending => yellow("pending"),
744                    OperatorFixOutcome::Addressed => green("addressed"),
745                    OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
746                    OperatorFixOutcome::Unreported => {
747                        red("unreported — no adoption report came back")
748                    }
749                };
750                let _ = writeln!(
751                    s,
752                    "      {} [{:?}] {}  {outcome}",
753                    dim(&f.id),
754                    f.severity,
755                    f.title
756                );
757            }
758            match &req.follow_up_review_run {
759                Some(id) => {
760                    let _ = writeln!(s, "      re-verified by run {id}");
761                }
762                None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
763                    let _ = writeln!(
764                        s,
765                        "      {}",
766                        red("committed, but the follow-up review could not be opened")
767                    );
768                }
769                None => {
770                    let _ = writeln!(s, "      no change committed; nothing to re-verify");
771                }
772            }
773        }
774    }
775
776    if let Some(bs) = &state.base_sync {
777        let _ = writeln!(s, "\n{}", bold("base sync"));
778        let status = if let Some(c) = &bs.conflict {
779            red(&format!("conflict: {}", first_line(c)))
780        } else if bs.behind == 0 {
781            green("in sync")
782        } else {
783            yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
784        };
785        let _ = writeln!(
786            s,
787            "  {} @ {}  {status}{}",
788            state.base_branch,
789            short(&bs.tip),
790            if bs.attempts > 0 {
791                format!("  ({} rebase attempt(s))", bs.attempts)
792            } else {
793                String::new()
794            }
795        );
796    }
797
798    // `state.gate.is_empty()` alone cannot tell "never ran" apart from "ran
799    // with nothing configured" — see `RunState::gate_status`'s own doc — so
800    // this reads the accessor rather than the raw list.
801    match state.gate_status() {
802        GateStatus::NotRun => {}
803        GateStatus::PassedWithNoCommands => {
804            let _ = writeln!(s, "\n{}", bold("gate"));
805            let _ = writeln!(s, "  {}  no gate commands configured", green("pass"));
806        }
807        GateStatus::Passed | GateStatus::Failed => {
808            let _ = writeln!(s, "\n{}", bold("gate"));
809            for o in &state.gate {
810                let _ = writeln!(
811                    s,
812                    "  {}  {}",
813                    if o.ok() { green("pass") } else { red("FAIL") },
814                    o.command
815                );
816                if !o.ok() {
817                    let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
818                }
819            }
820        }
821    }
822
823    if let Some(m) = &state.merge {
824        let _ = writeln!(s, "\n{}", bold("merge"));
825        if m.mode == MergeMode::None {
826            // `ok: true` here means "magi did nothing, as configured", not
827            // "landed" — a green `ok` next to a shell command reads as done,
828            // and the branch is still sitting unmerged.
829            let _ = writeln!(
830                s,
831                "  mode None  {}",
832                cyan("not landed — nothing to do by design")
833            );
834            if let Some(w) = state.winner() {
835                let _ = writeln!(
836                    s,
837                    "  branch {} still exists, unmerged into {}",
838                    w.branch, state.base_branch
839                );
840            }
841            // The squash caveat only applies to that one style: `--no-ff` and
842            // `--ff-only` never inherit a candidate's placeholder subject,
843            // since neither ever discards the pull request body `message`
844            // that `manual_merge_command` (graph.rs) already puts on the
845            // squash commit's `-m`.
846            let _ = writeln!(
847                s,
848                "  rebase onto {} before merging by hand{}",
849                state.base_branch,
850                if state.config.merge.style == MergeStyle::Squash {
851                    ", and pass an explicit commit message — a squash merge \
852                     otherwise inherits the candidate's placeholder subject"
853                } else {
854                    ""
855                }
856            );
857            let _ = writeln!(s, "  {}", m.detail.lines().next().unwrap_or(""));
858        } else {
859            let _ = writeln!(
860                s,
861                "  mode {:?}  {}\n  {}",
862                m.mode,
863                if m.ok {
864                    green("ok")
865                } else {
866                    yellow("not merged")
867                },
868                m.detail.lines().next().unwrap_or("")
869            );
870        }
871    }
872
873    if !state.leaks.is_empty() {
874        let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
875        for l in &state.leaks {
876            let _ = writeln!(s, "  {} x{} in {}", l.token, l.count, l.site);
877        }
878    }
879
880    if let Some(w) = state.winner()
881        && !w.folded
882    {
883        let _ = writeln!(
884            s,
885            "\n{} {}\n  branch {}",
886            bold("winner worktree"),
887            w.worktree.display(),
888            w.branch
889        );
890    }
891    s.push_str(&jobs_section(state));
892    s
893}
894
895/// The seats currently mid-answer, for `magi show` and the raw report route.
896///
897/// Separate from [`run`] on purpose: [`run`] is printed straight after `magi
898/// run` / `magi review`'s own `execute()`, and by then this process has
899/// nothing left in flight to report; the TUI does not track daemon liveness
900/// either. Only a caller reading someone *else's* run — `magi show <id>`, or
901/// the web UI's raw-report route — needs this, and both already know how to
902/// ask whether a daemon is currently driving it.
903///
904/// `live` is [`RunState::liveness`]: whether a daemon's heartbeat currently
905/// names this run, or — absent that claim — whether `driver_pid` still
906/// answers alive. An [`ActiveSeat`](crate::run::ActiveSeat) left behind by a
907/// killed process is not lied about as running just because nobody has
908/// cleared it from disk yet — see that type's own docs for why an entry
909/// alone is not proof of anything.
910pub fn active_seats(state: &RunState, live: Liveness) -> String {
911    if state.active.is_empty() {
912        return String::new();
913    }
914    let mut s = String::new();
915    let _ = writeln!(s, "\n{}", bold("running now"));
916    let now = jiff::Timestamp::now();
917    match live {
918        Liveness::Live => {}
919        Liveness::Dead => {
920            let _ = writeln!(
921                s,
922                "  {}",
923                yellow(
924                    "no live daemon claims this run right now — likely left behind by a killed process"
925                )
926            );
927        }
928        Liveness::Unknown => {
929            // `active_all_overrun` is never treated as proof of death here —
930            // only ever mentioned alongside "could not confirm", since a
931            // seat legitimately overrunning its budget while the process
932            // driving it tears the attempt down looks identical on disk (see
933            // `RunState::active_all_overrun`'s own doc).
934            let overrun = if state.active_all_overrun(now) {
935                " — every active seat has already run past its own timeout budget"
936            } else {
937                ""
938            };
939            let _ = writeln!(
940                s,
941                "  {}",
942                yellow(&format!(
943                    "whether a process is still driving this run could not be confirmed{overrun}"
944                ))
945            );
946        }
947    }
948    for (seat, a) in state.seats_active() {
949        let retry = if a.attempt > 0 {
950            format!(" retry {}", a.attempt)
951        } else {
952            String::new()
953        };
954        let _ = writeln!(
955            s,
956            "  {:<12} {:<12}{retry}  {}s elapsed, {}s left of {}s",
957            seat,
958            a.node,
959            a.elapsed_secs(now),
960            a.remaining_secs(now),
961            a.timeout_secs
962        );
963    }
964    for (task, a) in state.tasks_active() {
965        let retry = if a.attempt > 0 {
966            format!(" retry {}", a.attempt)
967        } else {
968            String::new()
969        };
970        let progress = match (a.index, a.total) {
971            (Some(i), Some(t)) => format!(" ({i}/{t})"),
972            _ => String::new(),
973        };
974        let _ = writeln!(
975            s,
976            "  {:<12} {:<12}{retry}{progress}  {}s elapsed, {}s left of {}s",
977            task,
978            a.node,
979            a.elapsed_secs(now),
980            a.remaining_secs(now),
981            a.timeout_secs
982        );
983        if let Some(command) = &a.command {
984            let _ = writeln!(s, "               {command}");
985        }
986    }
987    s
988}
989
990/// Aggregate tables, for `magi stats`.
991pub fn stats(stats: &Stats) -> String {
992    let t = &stats.totals;
993    let mut s = String::new();
994    let _ = writeln!(s, "{}", bold("runs"));
995    let _ = writeln!(
996        s,
997        "  {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
998        t.runs,
999        t.merged,
1000        t.ready,
1001        t.blocked,
1002        t.failed,
1003        t.completion_rate()
1004    );
1005    if t.tallied > 0 {
1006        let _ = writeln!(
1007            s,
1008            "  {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1009             {} of those changed a mind, {} converged to unanimous",
1010            t.tallied,
1011            t.split,
1012            t.split_rate(),
1013            t.deliberated,
1014            t.minds_changed,
1015            t.converged
1016        );
1017    }
1018
1019    if !stats.agents.is_empty() {
1020        let _ = writeln!(
1021            s,
1022            "\n{}",
1023            bold("implementation (relative, on this workload)")
1024        );
1025        let _ = writeln!(
1026            s,
1027            "  {:<14}{:>6}{:>8}{:>8}{:>8}",
1028            "agent", "won", "entered", "rate", "empty"
1029        );
1030        for a in &stats.agents {
1031            let _ = writeln!(
1032                s,
1033                "  {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
1034                a.agent,
1035                a.wins,
1036                a.entered,
1037                a.win_rate(),
1038                a.empty
1039            );
1040        }
1041    }
1042
1043    if !stats.reviewers.is_empty() {
1044        let _ = writeln!(s, "\n{}", bold("review"));
1045        let _ = writeln!(
1046            s,
1047            "  {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1048            "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1049        );
1050        for r in &stats.reviewers {
1051            let _ = writeln!(
1052                s,
1053                "  {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1054                r.agent,
1055                r.rounds,
1056                r.submitted,
1057                r.adopted_per_round(),
1058                r.precision(),
1059                r.unique_rate(),
1060                r.timeout_rate()
1061            );
1062        }
1063    }
1064
1065    if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1066        let _ = writeln!(s, "\n{}", bold("verification"));
1067        let _ = writeln!(
1068            s,
1069            "  {} rounds ran e2e, {} failed, {} of those with a clean static \
1070             review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1071            stats.e2e.rounds,
1072            stats.e2e.failures,
1073            stats.e2e.sole_detections,
1074            stats.e2e.sole_rate(),
1075            stats.e2e.deferred
1076        );
1077    }
1078    s
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use crate::config::Config;
1085    use crate::run::{
1086        Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1087        Tally,
1088    };
1089    use std::collections::BTreeMap;
1090    use std::path::PathBuf;
1091    use std::sync::{Mutex, MutexGuard};
1092
1093    /// `COLOR` is process-global, so these tests cannot run concurrently.
1094    static SERIAL: Mutex<()> = Mutex::new(());
1095
1096    fn plain() -> MutexGuard<'static, ()> {
1097        let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1098        set_color(false);
1099        guard
1100    }
1101
1102    fn state() -> RunState {
1103        // `run()` prints `state.dir()`, which reads the process-global home;
1104        // pinning it here keeps this test off the operator's real one. The
1105        // directory itself is never read, only its path printed, so nothing
1106        // needs to create or clean it up.
1107        crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
1108        let mut s = RunState::new(
1109            PathBuf::from("/repo"),
1110            "main".to_owned(),
1111            "abcdef1234".to_owned(),
1112            "add retries to the uploader".to_owned(),
1113            Config::default(),
1114        );
1115        s.candidates = vec![Candidate {
1116            index: 0,
1117            label: 'A',
1118            agent: "opus".to_owned(),
1119            branch: "magi/x/A".to_owned(),
1120            worktree: PathBuf::from("/wt/A"),
1121            summary: String::new(),
1122            stat: String::new(),
1123            files: 3,
1124            commits: 2,
1125            empty: false,
1126            failed: None,
1127            verified_noop: None,
1128            duration_ms: 42_000,
1129            folded: false,
1130        }];
1131        s.tally = Some(Tally {
1132            first_choice: BTreeMap::from([('A', 3)]),
1133            borda: BTreeMap::new(),
1134            winner: 'A',
1135            rankings: 3,
1136            unanimous_initial: true,
1137            deliberated: false,
1138            changed_votes: 0,
1139            unanimous_final: true,
1140            tie_break: None,
1141            judges: 3,
1142            present: 3,
1143            quorum: 2,
1144            met_quorum: true,
1145            uncontested: None,
1146        });
1147        s
1148    }
1149
1150    #[test]
1151    fn run_report_names_the_winner_and_its_author() {
1152        let _guard = plain();
1153        let text = run(&state());
1154        assert!(text.contains("<- winner"), "{text}");
1155        assert!(text.contains("opus"));
1156        assert!(text.contains("3 files, 2 commits"));
1157        assert!(text.contains("winner        A"));
1158        assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1159    }
1160
1161    #[test]
1162    fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1163        // Recorded on every run (`prompt::implement`'s `## SUMMARY`), but
1164        // `run()` used to never print it at all — silent even when the
1165        // summary was the one place an implementer explained itself (e.g.
1166        // an investigation task's findings), and readable only by opening
1167        // `run.json` by hand.
1168        let _guard = plain();
1169        let mut s = state();
1170        s.candidates[0].summary =
1171            "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1172                .to_owned();
1173        let text = run(&s);
1174        assert!(
1175            text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1176            "{text}"
1177        );
1178    }
1179
1180    #[test]
1181    fn a_verified_noop_run_does_not_read_as_a_failure() {
1182        // Same spirit as `a_mode_none_merge_does_not_read_as_landed`: a run
1183        // that settled without landing anything must not be misreadable as
1184        // the ordinary failure it is not.
1185        let _guard = plain();
1186        let mut s = state();
1187        s.status = RunStatus::VerifiedNoop;
1188        s.tally = None;
1189        s.candidates = vec![Candidate {
1190            index: 0,
1191            label: 'A',
1192            agent: "opus".to_owned(),
1193            branch: "magi/x/A".to_owned(),
1194            worktree: PathBuf::from("/wt/A"),
1195            summary: String::new(),
1196            stat: String::new(),
1197            files: 0,
1198            commits: 0,
1199            empty: true,
1200            failed: None,
1201            verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1202            duration_ms: 9_000,
1203            folded: false,
1204        }];
1205        let text = run(&s);
1206        assert!(
1207            text.contains("agent-verified no-op"),
1208            "the status and the candidate flag must both say so: {text}"
1209        );
1210        assert!(
1211            text.contains("already fixed by b32cfc4"),
1212            "the evidence itself must be readable, not just the verdict: {text}"
1213        );
1214        assert!(
1215            !text.to_lowercase().contains("failed"),
1216            "a verified no-op must never read as the failure it is not: {text}"
1217        );
1218    }
1219
1220    #[test]
1221    fn colour_is_emitted_only_when_enabled() {
1222        let _guard = plain();
1223        set_color(true);
1224        let coloured = run(&state());
1225        set_color(false);
1226        let plain = run(&state());
1227        assert!(coloured.contains('\x1b'));
1228        assert!(!plain.contains('\x1b'));
1229        assert!(coloured.len() > plain.len());
1230    }
1231
1232    #[test]
1233    fn list_line_is_single_line() {
1234        let _guard = plain();
1235        let l = line(&state());
1236        assert_eq!(l.lines().count(), 1);
1237        assert!(l.contains("add retries"));
1238        assert!(l.contains("win A (opus)"));
1239    }
1240
1241    #[test]
1242    fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1243        let _guard = plain();
1244        let mut s = state();
1245        s.tally = Some(Tally {
1246            first_choice: BTreeMap::from([('A', 0)]),
1247            borda: BTreeMap::new(),
1248            winner: 'A',
1249            rankings: 0,
1250            unanimous_initial: false,
1251            deliberated: false,
1252            changed_votes: 0,
1253            unanimous_final: false,
1254            tie_break: None,
1255            judges: 0,
1256            present: 0,
1257            quorum: 0,
1258            met_quorum: true,
1259            uncontested: Some(
1260                "only candidate A produced a usable change; no panel was asked".to_owned(),
1261            ),
1262        });
1263        let text = run(&s);
1264        assert!(
1265            !text.contains("0/3"),
1266            "no panel sat, so the judges line must not read as one that collapsed: {text}"
1267        );
1268        assert!(!text.contains("no usable ranking"), "{text}");
1269        assert!(!text.contains("still split"), "{text}");
1270        assert!(!text.contains("BELOW QUORUM"), "{text}");
1271        assert!(
1272            text.contains("not needed"),
1273            "the report must say judging was skipped, not silent: {text}"
1274        );
1275        assert!(text.contains("winner        A"));
1276    }
1277
1278    #[test]
1279    fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1280        let _guard = plain();
1281        let mut s = state();
1282        s.tally = Some(Tally {
1283            first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1284            borda: BTreeMap::new(),
1285            winner: 'A',
1286            rankings: 1,
1287            unanimous_initial: false,
1288            deliberated: false,
1289            changed_votes: 0,
1290            unanimous_final: false,
1291            tie_break: None,
1292            judges: 3,
1293            present: 1,
1294            quorum: 2,
1295            met_quorum: false,
1296            uncontested: None,
1297        });
1298        let text = run(&s);
1299        assert!(text.contains("1/3"), "{text}");
1300        assert!(
1301            text.contains("BELOW QUORUM"),
1302            "a real collapse must still be flagged: {text}"
1303        );
1304        assert!(
1305            !text.contains("not needed"),
1306            "a collapsed panel must not be described as one that was never asked: {text}"
1307        );
1308    }
1309
1310    #[test]
1311    fn a_mode_none_merge_does_not_read_as_landed() {
1312        let _guard = plain();
1313        let mut s = state();
1314        s.merge = Some(MergeOutcome {
1315            mode: crate::config::MergeMode::None,
1316            ok: true,
1317            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1318        });
1319        let text = run(&s);
1320        assert!(
1321            !text.contains("  ok"),
1322            "mode none must not be shown as a landed merge: {text}"
1323        );
1324        assert!(text.contains("not landed"), "{text}");
1325        assert!(
1326            text.contains("branch magi/x/A"),
1327            "the report must say what's left behind: {text}"
1328        );
1329        assert!(
1330            text.contains("rebase"),
1331            "the report must point at the hand-landing steps: {text}"
1332        );
1333        assert!(
1334            !text.contains("placeholder subject"),
1335            "the default merge style is `merge`, which never inherits a \
1336             placeholder subject, so the squash caveat must not appear: {text}"
1337        );
1338    }
1339
1340    #[test]
1341    fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1342        let _guard = plain();
1343        let mut s = state();
1344        s.config.merge.style = MergeStyle::Squash;
1345        s.merge = Some(MergeOutcome {
1346            mode: crate::config::MergeMode::None,
1347            ok: true,
1348            detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1349                      retries\""
1350                .to_owned(),
1351        });
1352        let text = run(&s);
1353        assert!(
1354            text.contains("placeholder subject"),
1355            "a squash-style manual merge must warn about the missing message: {text}"
1356        );
1357        assert!(text.contains("--squash"), "{text}");
1358    }
1359
1360    #[test]
1361    fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1362        let _guard = plain();
1363        let mut s = state();
1364        s.status = RunStatus::Ready;
1365        s.merge = Some(MergeOutcome {
1366            mode: crate::config::MergeMode::None,
1367            ok: true,
1368            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1369        });
1370
1371        let list = line(&s);
1372        assert!(
1373            !list.contains(" ready "),
1374            "a mode-none run must not read as a plain ready in `magi list`: {list}"
1375        );
1376        assert!(list.contains("no-op by design"), "{list}");
1377
1378        let full = run(&s);
1379        assert!(
1380            !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1381            "the header line of `magi show` must not say plain ready either: {full}"
1382        );
1383        assert!(full.contains("no-op by design"), "{full}");
1384    }
1385
1386    #[test]
1387    fn an_ordinary_ready_run_still_reads_as_ready() {
1388        let _guard = plain();
1389        let mut s = state();
1390        s.status = RunStatus::Ready;
1391        // A PR closed without merging also ends at `Ready` (see `land.rs`),
1392        // and unlike the honest mode-none no-op it must keep reading as a
1393        // plain `ready` — the label exists to flag design, not every non-merge.
1394        s.merge = Some(MergeOutcome {
1395            mode: crate::config::MergeMode::Pr,
1396            ok: false,
1397            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1398        });
1399
1400        let list = line(&s);
1401        assert!(list.contains("ready"), "{list}");
1402        assert!(!list.contains("no-op by design"), "{list}");
1403    }
1404
1405    #[test]
1406    fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1407        let _guard = plain();
1408        let mut s = state();
1409        s.tally = Some(Tally {
1410            first_choice: BTreeMap::from([('A', 0)]),
1411            borda: BTreeMap::new(),
1412            winner: 'A',
1413            rankings: 0,
1414            unanimous_initial: false,
1415            deliberated: false,
1416            changed_votes: 0,
1417            unanimous_final: false,
1418            tie_break: None,
1419            judges: 0,
1420            present: 0,
1421            quorum: 0,
1422            met_quorum: true,
1423            uncontested: Some("only candidate A produced a usable change".to_owned()),
1424        });
1425        let l = line(&s);
1426        assert!(
1427            !l.contains("judges") && !l.contains("quorum"),
1428            "an uncontested run must not carry the same badge a short panel gets: {l}"
1429        );
1430    }
1431
1432    #[test]
1433    fn long_instructions_are_elided() {
1434        let _guard = plain();
1435        let mut s = state();
1436        s.instruction = "x".repeat(200);
1437        assert!(line(&s).contains('…'));
1438    }
1439
1440    #[test]
1441    fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1442        let _guard = plain();
1443        let mut lost = state();
1444        lost.reviews = vec![ReviewRound {
1445            round: 1,
1446            head: "abc1234".to_owned(),
1447            verified_head: None,
1448            verified_at: None,
1449            reviews: Vec::new(),
1450            e2e: Vec::new(),
1451            verify_retried: false,
1452            e2e_deferred: false,
1453            e2e_defer_reason: None,
1454            fix: Some(FixRecord {
1455                agent: "opus".to_owned(),
1456                addressed: Vec::new(),
1457                rejected: Vec::new(),
1458                notes: String::new(),
1459                committed: true,
1460                failed: Some("timed out".to_owned()),
1461                duration_ms: 0,
1462                continuation: None,
1463            }),
1464            blocking: 3,
1465            answered: 0,
1466            expected: 0,
1467            clean: false,
1468            progressed: false,
1469            vote_split: false,
1470            reconsideration: Vec::new(),
1471            verdict: None,
1472        }];
1473        let text = run(&lost);
1474        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1475        assert!(
1476            !text.contains("0 addressed"),
1477            "a lost report must never read as `0 addressed`: {text}"
1478        );
1479
1480        let mut rejected_all = state();
1481        rejected_all.reviews = vec![ReviewRound {
1482            round: 1,
1483            head: "abc1234".to_owned(),
1484            verified_head: None,
1485            verified_at: None,
1486            reviews: Vec::new(),
1487            e2e: Vec::new(),
1488            verify_retried: false,
1489            e2e_deferred: false,
1490            e2e_defer_reason: None,
1491            fix: Some(FixRecord {
1492                agent: "opus".to_owned(),
1493                addressed: Vec::new(),
1494                rejected: Vec::new(),
1495                notes: String::new(),
1496                committed: true,
1497                failed: None,
1498                duration_ms: 0,
1499                continuation: None,
1500            }),
1501            blocking: 3,
1502            answered: 0,
1503            expected: 0,
1504            clean: false,
1505            progressed: false,
1506            vote_split: false,
1507            reconsideration: Vec::new(),
1508            verdict: None,
1509        }];
1510        let text2 = run(&rejected_all);
1511        assert!(
1512            text2.contains("0 addressed / 0 rejected"),
1513            "a round the fixer actually reported on keeps the count: {text2}"
1514        );
1515    }
1516
1517    #[test]
1518    fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1519        use crate::run::ReviewRevoteRecord;
1520        use crate::verdict::ReviewVote;
1521
1522        let _guard = plain();
1523        let mut s = state();
1524        s.reviews = vec![ReviewRound {
1525            round: 1,
1526            head: "abc1234".to_owned(),
1527            verified_head: None,
1528            verified_at: None,
1529            reviews: vec![
1530                ReviewRecord {
1531                    attempts: 0,
1532                    reviewer: 1,
1533                    agent: "alpha".to_owned(),
1534                    summary: String::new(),
1535                    findings: Vec::new(),
1536                    vote: Some(ReviewVote::Approve),
1537                    failed: None,
1538                    duration_ms: 0,
1539                },
1540                ReviewRecord {
1541                    attempts: 0,
1542                    reviewer: 2,
1543                    agent: "beta".to_owned(),
1544                    summary: String::new(),
1545                    findings: Vec::new(),
1546                    vote: Some(ReviewVote::Reject),
1547                    failed: None,
1548                    duration_ms: 0,
1549                },
1550            ],
1551            e2e: Vec::new(),
1552            verify_retried: false,
1553            e2e_deferred: false,
1554            e2e_defer_reason: None,
1555            fix: None,
1556            blocking: 0,
1557            answered: 2,
1558            expected: 2,
1559            clean: false,
1560            progressed: false,
1561            vote_split: true,
1562            reconsideration: vec![ReviewRevoteRecord {
1563                reviewer: 2,
1564                agent: "beta".to_owned(),
1565                vote: Some(ReviewVote::ApproveWithFindings),
1566                reason: "the other seat's read holds up".to_owned(),
1567                failed: None,
1568            }],
1569            verdict: Some(ReviewVote::ApproveWithFindings),
1570        }];
1571        let text = run(&s);
1572        assert!(text.contains("review-1 vote"), "{text}");
1573        assert!(text.contains("review-2 vote"), "{text}");
1574        assert!(text.contains("panel split"), "{text}");
1575        assert!(text.contains("reconsideration"), "{text}");
1576        assert!(text.contains("the other seat's read holds up"), "{text}");
1577    }
1578
1579    #[test]
1580    fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1581        // Two independent facts share this one line, and each arrived from a
1582        // different change: a seat that never answered, and a fixer whose
1583        // adoption report was lost. Rendering either must not shadow the
1584        // other, and neither may collapse into the plain `clean`/`open`
1585        // pair the line used to carry.
1586        let _guard = plain();
1587        let mut s = state();
1588        s.reviews = vec![ReviewRound {
1589            round: 1,
1590            head: "abc1234".to_owned(),
1591            verified_head: None,
1592            verified_at: None,
1593            reviews: vec![
1594                ReviewRecord {
1595                    attempts: 0,
1596                    reviewer: 1,
1597                    agent: "alpha".to_owned(),
1598                    summary: String::new(),
1599                    findings: Vec::new(),
1600                    vote: None,
1601                    failed: None,
1602                    duration_ms: 0,
1603                },
1604                ReviewRecord {
1605                    attempts: 0,
1606                    reviewer: 2,
1607                    agent: "beta".to_owned(),
1608                    summary: String::new(),
1609                    findings: Vec::new(),
1610                    vote: None,
1611                    failed: Some("agent timed out".to_owned()),
1612                    duration_ms: 0,
1613                },
1614            ],
1615            e2e: Vec::new(),
1616            verify_retried: false,
1617            e2e_deferred: false,
1618            e2e_defer_reason: None,
1619            fix: Some(FixRecord {
1620                agent: "opus".to_owned(),
1621                addressed: Vec::new(),
1622                rejected: Vec::new(),
1623                notes: String::new(),
1624                committed: true,
1625                failed: Some("timed out".to_owned()),
1626                duration_ms: 0,
1627                continuation: None,
1628            }),
1629            blocking: 0,
1630            answered: 1,
1631            expected: 2,
1632            clean: false,
1633            progressed: true,
1634            vote_split: false,
1635            reconsideration: Vec::new(),
1636            verdict: None,
1637        }];
1638        let text = run(&s);
1639        assert!(text.contains("incomplete"), "{text}");
1640        assert!(text.contains("1/2 reviewers answered"), "{text}");
1641        assert!(text.contains("review-2: agent timed out"), "{text}");
1642        assert!(text.contains("adoption report lost (timed out)"), "{text}");
1643        assert!(
1644            !text.contains("clean"),
1645            "a round missing half its panel must never render as clean: {text}"
1646        );
1647    }
1648
1649    #[test]
1650    fn a_build_failure_is_not_reported_as_a_test_failure() {
1651        let _guard = plain();
1652        let mut s = state();
1653        s.reviews = vec![ReviewRound {
1654            round: 1,
1655            head: "abc1234".to_owned(),
1656            verified_head: None,
1657            verified_at: None,
1658            reviews: Vec::new(),
1659            e2e: vec![CommandOutcome {
1660                command: "cargo test".to_owned(),
1661                code: Some(1),
1662                output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1663                duration_ms: 100,
1664                resource_blocked: false,
1665            }],
1666            verify_retried: true,
1667            e2e_deferred: false,
1668            e2e_defer_reason: None,
1669            fix: None,
1670            blocking: 0,
1671            answered: 0,
1672            expected: 0,
1673            clean: false,
1674            progressed: false,
1675            vote_split: false,
1676            reconsideration: Vec::new(),
1677            verdict: None,
1678        }];
1679        let text = run(&s);
1680        assert!(text.contains("could not run"), "{text}");
1681        assert!(text.contains("retried once"), "{text}");
1682        assert!(!text.contains("e2e RED"), "{text}");
1683    }
1684
1685    #[test]
1686    fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_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: "(waiting for the shared build cache)".to_owned(),
1697                code: None,
1698                output_tail: "held by run x node e2e seat e2e".to_owned(),
1699                duration_ms: 100,
1700                resource_blocked: true,
1701            }],
1702            verify_retried: false,
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("shared build cache unavailable"), "{text}");
1717        assert!(!text.contains("e2e RED"), "{text}");
1718        assert!(!text.contains("build/link failure"), "{text}");
1719    }
1720
1721    #[test]
1722    fn a_round_with_more_than_one_e2e_command_names_each_one() {
1723        // The aggregate `e2e RED` label says the round's overall verdict,
1724        // never which of several `verify.e2e` commands actually failed —
1725        // `magi show` must list each command by name, the same way it
1726        // already does for `gate`.
1727        let _guard = plain();
1728        let mut s = state();
1729        s.reviews = vec![ReviewRound {
1730            round: 1,
1731            head: "abc1234".to_owned(),
1732            verified_head: Some("abc1234".to_owned()),
1733            verified_at: Some(jiff::Timestamp::now()),
1734            reviews: Vec::new(),
1735            e2e: vec![
1736                CommandOutcome {
1737                    command: "cargo test --locked --all-targets".to_owned(),
1738                    code: Some(0),
1739                    output_tail: String::new(),
1740                    duration_ms: 0,
1741                    resource_blocked: false,
1742                },
1743                CommandOutcome {
1744                    command: "cargo make check".to_owned(),
1745                    code: Some(1),
1746                    output_tail: "clippy: unused import".to_owned(),
1747                    duration_ms: 0,
1748                    resource_blocked: false,
1749                },
1750            ],
1751            verify_retried: false,
1752            e2e_deferred: false,
1753            e2e_defer_reason: None,
1754            fix: None,
1755            blocking: 0,
1756            answered: 0,
1757            expected: 0,
1758            clean: false,
1759            progressed: false,
1760            vote_split: false,
1761            reconsideration: Vec::new(),
1762            verdict: None,
1763        }];
1764        let text = run(&s);
1765        assert!(text.contains("cargo test --locked --all-targets"), "{text}");
1766        assert!(text.contains("cargo make check"), "{text}");
1767        assert!(text.contains("clippy: unused import"), "{text}");
1768    }
1769
1770    #[test]
1771    fn a_declined_finding_shows_its_reason() {
1772        use crate::verdict::{Finding, Rejection, Severity};
1773
1774        let _guard = plain();
1775        let mut s = state();
1776        s.status = RunStatus::Ready;
1777        s.reviews = vec![ReviewRound {
1778            round: 1,
1779            head: "deadbee".to_owned(),
1780            verified_head: None,
1781            verified_at: None,
1782            reviews: vec![ReviewRecord {
1783                attempts: 0,
1784                reviewer: 1,
1785                agent: "alpha".to_owned(),
1786                summary: String::new(),
1787                findings: vec![Finding {
1788                    id: "R1-1-1".to_owned(),
1789                    severity: Severity::Major,
1790                    file: None,
1791                    line: None,
1792                    title: "still open".to_owned(),
1793                    detail: String::new(),
1794                }],
1795                vote: None,
1796                failed: None,
1797                duration_ms: 0,
1798            }],
1799            e2e: vec![CommandOutcome {
1800                command: "cargo test".to_owned(),
1801                code: Some(0),
1802                output_tail: String::new(),
1803                duration_ms: 0,
1804                resource_blocked: false,
1805            }],
1806            verify_retried: false,
1807            e2e_deferred: false,
1808            e2e_defer_reason: None,
1809            fix: Some(FixRecord {
1810                agent: "alpha".to_owned(),
1811                addressed: Vec::new(),
1812                rejected: vec![Rejection {
1813                    id: "R1-1-2".to_owned(),
1814                    why: "cannot be triggered from any caller".to_owned(),
1815                }],
1816                notes: String::new(),
1817                committed: true,
1818                failed: None,
1819                duration_ms: 0,
1820                continuation: None,
1821            }),
1822            blocking: 1,
1823            answered: 1,
1824            expected: 1,
1825            clean: false,
1826            progressed: true,
1827            vote_split: false,
1828            reconsideration: Vec::new(),
1829            verdict: None,
1830        }];
1831
1832        let text = run(&s);
1833        assert!(text.contains("R1-1-2"), "{text}");
1834        assert!(text.contains("cannot be triggered"), "{text}");
1835        assert!(text.contains("still open"), "{text}");
1836        assert!(
1837            text.contains("handed off"),
1838            "a mergeable run with an open round must say so: {text}"
1839        );
1840    }
1841
1842    #[test]
1843    fn a_failing_gate_command_shows_its_output() {
1844        let _guard = plain();
1845        let mut s = state();
1846        s.status = RunStatus::Blocked;
1847        s.gate = vec![CommandOutcome {
1848            command: "cargo make check".to_owned(),
1849            code: Some(101),
1850            output_tail: "error[E0308]: mismatched types".to_owned(),
1851            duration_ms: 0,
1852            resource_blocked: false,
1853        }];
1854        s.gate_ran = true;
1855
1856        let text = run(&s);
1857        assert!(text.contains("mismatched types"), "{text}");
1858    }
1859
1860    #[test]
1861    fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
1862        let _guard = plain();
1863        let mut s = state();
1864        s.status = RunStatus::Ready;
1865        s.gate_ran = true;
1866        assert!(s.gate.is_empty());
1867
1868        let text = run(&s);
1869        assert!(
1870            text.contains("gate") && text.contains("no gate commands configured"),
1871            "a run gated on nothing must say so, not read as if the gate never ran: {text}"
1872        );
1873    }
1874
1875    #[test]
1876    fn a_gate_that_has_not_run_yet_shows_nothing() {
1877        let _guard = plain();
1878        let s = state();
1879        assert!(!s.gate_ran);
1880        assert!(s.gate.is_empty());
1881
1882        let text = run(&s);
1883        assert!(
1884            !text.contains("no gate commands configured"),
1885            "an unattempted gate must not be shown as a pass: {text}"
1886        );
1887    }
1888
1889    #[test]
1890    fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1891        let _guard = plain();
1892        let mut s = state();
1893        s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1894        let text = active_seats(&s, Liveness::Live);
1895        assert!(text.contains("running now"));
1896        assert!(text.contains("judge-2"));
1897        assert!(text.contains("judge"));
1898        assert!(!text.contains("no live daemon"), "{text}");
1899        assert!(!text.contains("could not be confirmed"), "{text}");
1900    }
1901
1902    #[test]
1903    fn active_seats_flags_a_leftover_from_a_dead_process() {
1904        let _guard = plain();
1905        let mut s = state();
1906        s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1907        let text = active_seats(&s, Liveness::Dead);
1908        assert!(
1909            text.contains("no live daemon"),
1910            "a stale entry must not read as running: {text}"
1911        );
1912    }
1913
1914    #[test]
1915    fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
1916        let _guard = plain();
1917        let mut s = state();
1918        s.status = RunStatus::Reviewing;
1919        let text = line_with_liveness(&s, Liveness::Dead);
1920        assert!(text.contains("STALE"), "{text}");
1921        assert!(text.contains("resume required"), "{text}");
1922        assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
1923    }
1924
1925    /// A manual `magi run` / `magi review` claims no daemon, but its
1926    /// `driver_pid` still answers — this is `Unknown`, never `Dead`: see
1927    /// `RunState::liveness`'s own doc for why an unconfirmed process is never
1928    /// folded into "confirmed dead".
1929    #[test]
1930    fn active_seats_reports_uncertainty_without_claiming_death() {
1931        let _guard = plain();
1932        let mut s = state();
1933        s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
1934        let text = active_seats(&s, Liveness::Unknown);
1935        assert!(
1936            text.contains("could not be confirmed"),
1937            "an unproven state must read as uncertain, not dead: {text}"
1938        );
1939        assert!(!text.contains("no live daemon"), "{text}");
1940    }
1941
1942    #[test]
1943    fn active_seats_is_empty_when_nothing_is_running() {
1944        let _guard = plain();
1945        assert_eq!(active_seats(&state(), Liveness::Live), "");
1946    }
1947
1948    /// A running `verify.e2e` / `verify.gate` task is shown alongside seats,
1949    /// with which command is currently running and how far through the list
1950    /// it is — the gap the addendum's second item names: a review-only run
1951    /// that spends minutes inside `cargo test` between reviewer answers and
1952    /// the fix's own commit had nothing at all to show for it before this.
1953    #[test]
1954    fn active_seats_shows_a_running_verify_task_and_its_command() {
1955        let _guard = plain();
1956        let mut s = state();
1957        s.task_command(
1958            "e2e",
1959            "verify",
1960            0,
1961            "cargo test",
1962            2,
1963            3,
1964            std::time::Duration::from_secs(600),
1965        );
1966        let text = active_seats(&s, Liveness::Live);
1967        assert!(text.contains("e2e"), "{text}");
1968        assert!(text.contains("(2/3)"), "{text}");
1969        assert!(text.contains("cargo test"), "{text}");
1970    }
1971
1972    #[test]
1973    fn no_jobs_section_appears_when_nothing_was_ever_collected() {
1974        let _guard = plain();
1975        // The common case today (every backend but codex): silence, not a
1976        // clutter line repeated on every single `magi show`.
1977        assert!(!run(&state()).contains("background jobs"));
1978    }
1979
1980    #[test]
1981    fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
1982        let _guard = plain();
1983        let mut s = state();
1984        s.config.agents.push(crate::config::AgentSpec {
1985            id: "codex-one".to_owned(),
1986            kind: crate::config::AgentKind::Codex,
1987            model: None,
1988            command: vec!["codex".to_owned()],
1989            extra_args: Vec::new(),
1990            env: BTreeMap::new(),
1991            prompt_delivery: None,
1992        });
1993        let text = run(&s);
1994        assert!(
1995            text.contains("background jobs"),
1996            "a run that could report this must not read the same as one that never could: \
1997             {text}"
1998        );
1999        assert!(text.contains("no completed command evidence yet"));
2000    }
2001
2002    #[test]
2003    fn recovered_running_and_unreadable_jobs_are_told_apart() {
2004        let _guard = plain();
2005        let mut s = state();
2006        s.jobs = vec![
2007            crate::run::JobRecord {
2008                node: "implement".to_owned(),
2009                round: None,
2010                seat: "impl-A".to_owned(),
2011                id: "item49".to_owned(),
2012                description: "cargo test --test graph_cached_gate".to_owned(),
2013                checked_at: jiff::Timestamp::now(),
2014                status: crate::run::JobStatus::Completed,
2015                exit_code: Some(0),
2016                result_summary: "test result: 2 passed; 0 failed".to_owned(),
2017                source: "codex".to_owned(),
2018            },
2019            crate::run::JobRecord {
2020                node: "fix".to_owned(),
2021                round: None,
2022                seat: "impl-A".to_owned(),
2023                id: "item52".to_owned(),
2024                description: "cargo test --test graph_split".to_owned(),
2025                checked_at: jiff::Timestamp::now(),
2026                status: crate::run::JobStatus::Failed,
2027                exit_code: Some(101),
2028                result_summary: "test result: 1 passed; 1 failed".to_owned(),
2029                source: "codex".to_owned(),
2030            },
2031            crate::run::JobRecord {
2032                node: "fix".to_owned(),
2033                round: None,
2034                seat: "impl-A".to_owned(),
2035                id: "item60".to_owned(),
2036                description: "cargo build".to_owned(),
2037                checked_at: jiff::Timestamp::now(),
2038                status: crate::run::JobStatus::Unknown,
2039                exit_code: None,
2040                result_summary: String::new(),
2041                source: "codex".to_owned(),
2042            },
2043        ];
2044        let text = run(&s);
2045        assert!(text.contains("background jobs"));
2046        assert!(text.contains("item49"));
2047        assert!(text.contains("item52"));
2048        assert!(text.contains("item60"));
2049        // The three states this run actually has evidence for must read
2050        // differently from one another — never collapsed into a single
2051        // "ran" or "did not run".
2052        assert!(text.contains("completed"));
2053        assert!(text.contains("failed"));
2054        assert!(text.contains("unknown"));
2055        // Coverage limit stated once, not fabricated per seat.
2056        assert!(text.contains("adapter coverage"));
2057    }
2058
2059    #[test]
2060    fn a_jobs_own_round_is_shown_when_known() {
2061        let _guard = plain();
2062        let mut s = state();
2063        s.jobs = vec![crate::run::JobRecord {
2064            node: "review".to_owned(),
2065            round: Some(2),
2066            seat: "review-1".to_owned(),
2067            id: "item9".to_owned(),
2068            description: "cargo test --test graph_cached_gate".to_owned(),
2069            checked_at: jiff::Timestamp::now(),
2070            status: crate::run::JobStatus::Completed,
2071            exit_code: Some(0),
2072            result_summary: "test result: 2 passed; 0 failed".to_owned(),
2073            source: "codex".to_owned(),
2074        }];
2075        let text = run(&s);
2076        assert!(
2077            text.contains("round 2"),
2078            "the round this seat's own command ran in must be visible, distinct from magi's \
2079             own recorded verify: {text}"
2080        );
2081    }
2082
2083    #[test]
2084    fn stats_table_renders_without_runs() {
2085        let _guard = plain();
2086        let text = stats(&Stats::default());
2087        assert!(text.contains("0 total"));
2088        assert!(!text.contains("implementation"));
2089    }
2090}