1use 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
25pub 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
57fn 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 RunStatus::VerifiedNoop => cyan(text),
85 RunStatus::Superseded => dim(text),
88 RunStatus::AlreadyInBase => dim(text),
91 _ => cyan(text),
92 }
93}
94
95fn 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
107pub fn line(state: &RunState) -> String {
109 line_with_liveness(state, Liveness::Unknown)
110}
111
112pub 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 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 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
160pub 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
174pub 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
210fn 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
224fn jobs_section(state: &RunState) -> String {
245 let mut s = String::new();
246 if state.jobs.is_empty() {
247 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
322pub 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 (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 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 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 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 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 let status = if r.incomplete() {
621 yellow("incomplete")
622 } else if r.clean {
623 green("clean")
624 } else {
625 yellow("open")
626 };
627 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 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 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 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 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 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 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 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
1087pub 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 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
1182fn 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
1197fn 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
1211pub fn repo_summary(repos: &[RepoStats]) -> String {
1217 let mut s = String::new();
1218 let _ = writeln!(s, "{}", bold("repos"));
1219
1220 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
1248pub 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 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 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 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 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 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 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 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 #[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 #[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 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 assert!(text.contains("completed"));
2424 assert!(text.contains("failed"));
2425 assert!(text.contains("unknown"));
2426 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 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}