Skip to main content

magi/
report.rs

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