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 !state.reviews.is_empty() {
564 let _ = writeln!(s, "\n{}", bold("review + verification"));
565 for r in &state.reviews {
566 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
567 let e2e = match r.e2e_status() {
575 E2eStatus::NotConfigured => dim("no e2e"),
576 E2eStatus::Deferred => yellow(&format!(
577 "e2e deferred{}",
578 r.e2e_defer_reason
579 .as_deref()
580 .map(|why| format!(" ({why})"))
581 .unwrap_or_default()
582 )),
583 E2eStatus::Passed => green("e2e green"),
584 E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
585 yellow("e2e could not run (build/link failure)")
586 }
587 E2eStatus::Failed => red("e2e RED"),
588 E2eStatus::ResourceBlocked => {
593 yellow("e2e could not run (shared build cache unavailable)")
594 }
595 };
596 let e2e = if r.verify_retried {
597 format!("{e2e}, retried once")
598 } else {
599 e2e
600 };
601 let status = if r.incomplete() {
605 yellow("incomplete")
606 } else if r.clean {
607 green("clean")
608 } else {
609 yellow("open")
610 };
611 let panel = if r.incomplete() {
615 let missing: Vec<String> = r
616 .reviews
617 .iter()
618 .filter_map(|x| {
619 x.failed
620 .as_ref()
621 .map(|why| format!("review-{}: {why}", x.reviewer))
622 })
623 .collect();
624 format!(
625 " {}/{} reviewers answered ({})",
626 r.answered,
627 r.expected,
628 missing.join(", ")
629 )
630 } else {
631 String::new()
632 };
633 let verdict = r.verdict.map_or(String::new(), |v| {
639 format!(
640 ", verdict {}{}",
641 vote_tag(v),
642 if r.vote_split { " (panel split)" } else { "" }
643 )
644 });
645 let _ = writeln!(
646 s,
647 " round {} {} @ {}{}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
648 r.round,
649 status,
650 short(&r.head),
651 r.verified_head.as_ref().map_or(String::new(), |head| {
652 format!(
653 " (verified @ {}{})",
654 short(head),
655 r.verified_at.map_or(String::new(), |t| format!(
656 " on {}",
657 t.to_zoned(jiff::tz::TimeZone::system())
658 .strftime("%Y-%m-%d %H:%M:%S")
659 ))
660 )
661 }),
662 r.blocking,
663 r.fix.as_ref().map_or(String::new(), |f| {
664 let tree = if r.progressed {
665 green("changed")
666 } else {
667 yellow("unchanged")
668 };
669 let cont = f
670 .continuation
671 .as_ref()
672 .map_or(String::new(), continuation_note);
673 match &f.failed {
674 Some(reason) => format!(
679 " fix: {}, tree {tree}{}{cont}",
680 yellow(&format!("adoption report lost ({reason})")),
681 if f.committed {
682 String::new()
683 } else {
684 red(" (NO COMMIT)")
685 }
686 ),
687 None => format!(
688 " fix: {} addressed / {} rejected, tree {tree}{}{cont}",
689 f.addressed.len(),
690 f.rejected.len(),
691 if f.committed {
692 String::new()
693 } else {
694 red(" (NO COMMIT)")
695 }
696 ),
697 }
698 })
699 );
700 for o in &r.e2e {
707 let label = if o.resource_blocked {
708 yellow("blocked")
709 } else if o.ok() {
710 green("pass")
711 } else {
712 red("FAIL")
713 };
714 let _ = writeln!(s, " {label} {}", o.command);
715 if !o.ok() {
716 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
717 }
718 }
719 for rec in &r.reviews {
720 if let Some(vote) = rec.vote {
721 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
722 }
723 for f in &rec.findings {
724 let adopted = r
725 .fix
726 .as_ref()
727 .is_some_and(|fix| fix.addressed.contains(&f.id));
728 let _ = writeln!(
729 s,
730 " {} [{:?}] {}{}",
731 dim(&f.id),
732 f.severity,
733 f.title,
734 if adopted {
735 green(" fixed")
736 } else {
737 String::new()
738 }
739 );
740 }
741 }
742 if let Some(fix) = &r.fix {
743 for rej in &fix.rejected {
744 let _ = writeln!(
745 s,
746 " {} {}: {}",
747 dim(&rej.id),
748 yellow("declined"),
749 rej.why
750 );
751 }
752 }
753 if !r.reconsideration.is_empty() {
757 let _ = writeln!(s, " {}", dim("reconsideration:"));
758 for rv in &r.reconsideration {
759 match rv.vote {
760 Some(v) => {
761 let _ = writeln!(
762 s,
763 " review-{} -> {} {}",
764 rv.reviewer,
765 vote_tag(v),
766 rv.reason
767 );
768 }
769 None => {
770 let _ = writeln!(
771 s,
772 " review-{} -> {}",
773 rv.reviewer,
774 red(&format!(
775 "no revote ({})",
776 rv.failed.as_deref().unwrap_or("unknown")
777 ))
778 );
779 }
780 }
781 }
782 }
783 }
784 if state.handed_off_with_open_findings() {
785 let _ = writeln!(
786 s,
787 "\n {}",
788 yellow(&format!(
789 "handed off with {} finding(s) still open — gate and e2e were green; \
790 see above for what a person should still look at",
791 state.open_findings().len()
792 ))
793 );
794 }
795 }
796
797 if !state.operator_fixes.is_empty() {
798 let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
799 for (i, req) in state.operator_fixes.iter().enumerate() {
800 let _ = writeln!(
801 s,
802 " [{}] {} finding(s) at {}{}",
803 i + 1,
804 req.findings.len(),
805 req.requested_at
806 .to_zoned(jiff::tz::TimeZone::system())
807 .strftime("%Y-%m-%d %H:%M:%S"),
808 if req.stale {
809 yellow(" stale head, --allow-stale used")
810 } else {
811 String::new()
812 }
813 );
814 let _ = writeln!(s, " reason: {}", req.reason);
815 for f in &req.findings {
816 let outcome = match &f.outcome {
817 OperatorFixOutcome::Pending => yellow("pending"),
818 OperatorFixOutcome::Addressed => green("addressed"),
819 OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
820 OperatorFixOutcome::Unreported => {
821 red("unreported — no adoption report came back")
822 }
823 };
824 let _ = writeln!(
825 s,
826 " {} [{:?}] {} {outcome}",
827 dim(&f.id),
828 f.severity,
829 f.title
830 );
831 }
832 match &req.follow_up_review_run {
833 Some(id) => {
834 let _ = writeln!(s, " re-verified by run {id}");
835 }
836 None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
837 let _ = writeln!(
838 s,
839 " {}",
840 red("committed, but the follow-up review could not be opened")
841 );
842 }
843 None => {
844 let _ = writeln!(s, " no change committed; nothing to re-verify");
845 }
846 }
847 }
848 }
849
850 if let Some(bs) = &state.base_sync {
851 let _ = writeln!(s, "\n{}", bold("base sync"));
852 let status = if let Some(a) = &bs.already_in {
853 dim(&format!(
854 "already in {} as {} ({} match) - nothing left to land",
855 state.base_branch,
856 a.names(),
857 a.proof.as_str()
858 ))
859 } else if let Some(c) = &bs.conflict {
860 red(&format!("conflict: {}", first_line(c)))
861 } else if bs.behind == 0 {
862 green("in sync")
863 } else {
864 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
865 };
866 let _ = writeln!(
867 s,
868 " {} @ {} {status}{}",
869 state.base_branch,
870 short(&bs.tip),
871 if bs.attempts > 0 {
872 format!(" ({} rebase attempt(s))", bs.attempts)
873 } else {
874 String::new()
875 }
876 );
877 }
878
879 if !state.pre_gate.is_empty() {
880 let _ = writeln!(s, "\n{}", bold("pre_gate"));
881 for o in &state.pre_gate {
882 let _ = writeln!(
883 s,
884 " {} {}",
885 if o.ok() {
886 green("pass")
887 } else {
888 yellow("warn")
889 },
890 o.command
891 );
892 if !o.ok() {
893 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
894 }
895 }
896 if let Some(c) = &state.pre_gate_commit {
897 let _ = writeln!(s, " committed mechanical fixes @ {}", short(c));
898 }
899 }
900
901 match state.gate_status() {
905 GateStatus::NotRun => {}
906 GateStatus::PassedWithNoCommands => {
907 let _ = writeln!(s, "\n{}", bold("gate"));
908 let _ = writeln!(s, " {} no gate commands configured", green("pass"));
909 }
910 GateStatus::Passed | GateStatus::Failed => {
911 let _ = writeln!(s, "\n{}", bold("gate"));
912 for o in &state.gate {
913 let _ = writeln!(
914 s,
915 " {} {}",
916 if o.ok() { green("pass") } else { red("FAIL") },
917 o.command
918 );
919 if !o.ok() {
920 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
921 }
922 }
923 }
924 }
925
926 if let Some(m) = &state.merge {
927 let _ = writeln!(s, "\n{}", bold("merge"));
928 if m.mode == MergeMode::None {
929 let _ = writeln!(
933 s,
934 " mode None {}",
935 cyan("not landed — nothing to do by design")
936 );
937 if let Some(w) = state.winner() {
938 let _ = writeln!(
939 s,
940 " branch {} still exists, unmerged into {}",
941 w.branch, state.base_branch
942 );
943 }
944 let _ = writeln!(
950 s,
951 " rebase onto {} before merging by hand{}",
952 state.base_branch,
953 if state.config.merge.style == MergeStyle::Squash {
954 ", and pass an explicit commit message — a squash merge \
955 otherwise inherits the candidate's placeholder subject"
956 } else {
957 ""
958 }
959 );
960 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
961 } else {
962 let _ = writeln!(
963 s,
964 " mode {:?} {}\n {}",
965 m.mode,
966 if m.ok {
967 green("ok")
968 } else if m.empty {
969 yellow("empty candidate — nothing to land")
970 } else {
971 yellow("not merged")
972 },
973 m.detail.lines().next().unwrap_or("")
974 );
975 }
976 if let Some(pr) = &state.pr
977 && !pr.red_at_merge.is_empty()
978 {
979 let _ = writeln!(
980 s,
981 " {} {}",
982 yellow("merged with red checks:"),
983 pr.red_at_merge.join(", ")
984 );
985 }
986 }
987
988 if !state.followups.is_empty() {
989 let _ = writeln!(s, "\n{}", bold("follow-ups"));
990 for f in &state.followups {
991 let _ = writeln!(
992 s,
993 " {} {}",
994 crate::queue::short(&f.task),
995 f.findings.join(", ")
996 );
997 }
998 }
999
1000 if let Some(b) = &state.release_bump {
1001 let _ = writeln!(s, "\n{}", bold("release bump"));
1002 if let Some(v) = &b.version {
1003 let _ = writeln!(s, " version v{v}");
1004 }
1005 if let Some(url) = &b.pr_url {
1006 let _ = writeln!(s, " pr {url}");
1007 }
1008 let _ = writeln!(
1009 s,
1010 " automerge {}",
1011 if b.automerge_enabled {
1012 green("enabled")
1013 } else if b.merged_directly {
1014 green("not needed (CI was already green; merged directly)")
1015 } else if b.local {
1016 dim(
1017 "not used (release.mode = local: merged on your approval, released by magi serve)",
1018 )
1019 } else if b.problem.is_some() {
1020 bold(&red("FAILED"))
1021 } else {
1022 dim("not enabled")
1023 }
1024 );
1025 if let Some(p) = &b.problem {
1026 let _ = writeln!(s, " {}", p.lines().next().unwrap_or(""));
1027 }
1028 if let Some(j) = &b.release {
1029 let state = if j.finished {
1030 "finished"
1031 } else {
1032 j.failed.as_deref().unwrap_or("unfinished")
1033 };
1034 let _ = writeln!(s, " release {} at {}: {state}", j.tag(), j.commit);
1035 for l in &j.log {
1036 let code = l.code.map_or("none".to_owned(), |c| c.to_string());
1037 let _ = write!(s, " {} (exit {code})", l.name);
1038 if let Some(o) = &l.output {
1039 let _ = write!(s, " output {o}");
1040 }
1041 let _ = writeln!(s);
1042 }
1043 }
1044 if let Some(a) = &b.action_required {
1045 let _ = writeln!(s, " {} {a}", bold(&yellow("action required:")));
1046 }
1047 }
1048
1049 if !state.leaks.is_empty() {
1050 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
1051 for l in &state.leaks {
1052 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
1053 }
1054 }
1055
1056 if let Some(w) = state.winner()
1057 && !w.folded
1058 {
1059 let _ = writeln!(
1060 s,
1061 "\n{} {}\n branch {}",
1062 bold("winner worktree"),
1063 w.worktree.display(),
1064 w.branch
1065 );
1066 }
1067 s.push_str(&jobs_section(state));
1068 s
1069}
1070
1071pub fn active_seats(state: &RunState, live: Liveness) -> String {
1087 if state.active.is_empty() {
1088 return String::new();
1089 }
1090 let mut s = String::new();
1091 let _ = writeln!(s, "\n{}", bold("running now"));
1092 let now = jiff::Timestamp::now();
1093 match live {
1094 Liveness::Live => {}
1095 Liveness::Dead => {
1096 let _ = writeln!(
1097 s,
1098 " {}",
1099 yellow(
1100 "no live daemon claims this run right now — likely left behind by a killed process"
1101 )
1102 );
1103 }
1104 Liveness::Unknown => {
1105 let overrun = if state.active_all_overrun(now) {
1111 " — every active seat has already run past its own timeout budget"
1112 } else {
1113 ""
1114 };
1115 let _ = writeln!(
1116 s,
1117 " {}",
1118 yellow(&format!(
1119 "whether a process is still driving this run could not be confirmed{overrun}"
1120 ))
1121 );
1122 }
1123 }
1124 for (seat, a) in state.seats_active() {
1125 let retry = if a.attempt > 0 {
1126 format!(" retry {}", a.attempt)
1127 } else {
1128 String::new()
1129 };
1130 let _ = writeln!(
1131 s,
1132 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
1133 seat,
1134 a.node,
1135 a.elapsed_secs(now),
1136 a.remaining_secs(now),
1137 a.timeout_secs
1138 );
1139 }
1140 for (task, a) in state.tasks_active() {
1141 let retry = if a.attempt > 0 {
1142 format!(" retry {}", a.attempt)
1143 } else {
1144 String::new()
1145 };
1146 let progress = match (a.index, a.total) {
1147 (Some(i), Some(t)) => format!(" ({i}/{t})"),
1148 _ => String::new(),
1149 };
1150 let _ = writeln!(
1151 s,
1152 " {:<12} {:<12}{retry}{progress} {}s elapsed, {}s left of {}s",
1153 task,
1154 a.node,
1155 a.elapsed_secs(now),
1156 a.remaining_secs(now),
1157 a.timeout_secs
1158 );
1159 if let Some(command) = &a.command {
1160 let _ = writeln!(s, " {command}");
1161 }
1162 }
1163 s
1164}
1165
1166fn fmt_secs(secs: i64) -> String {
1168 let secs = secs.max(0);
1169 let h = secs / 3600;
1170 let m = (secs % 3600) / 60;
1171 let s = secs % 60;
1172 if h > 0 {
1173 format!("{h}h{m:02}m")
1174 } else if m > 0 {
1175 format!("{m}m{s:02}s")
1176 } else {
1177 format!("{s}s")
1178 }
1179}
1180
1181fn bar(value: f64, max: f64, width: usize) -> String {
1186 if max <= 0.0 || value <= 0.0 {
1187 return String::new();
1188 }
1189 let cells = ((value / max) * width as f64)
1190 .round()
1191 .clamp(1.0, width as f64) as usize;
1192 "█".repeat(cells)
1193}
1194
1195pub fn repo_summary(repos: &[RepoStats]) -> String {
1201 let mut s = String::new();
1202 let _ = writeln!(s, "{}", bold("repos"));
1203
1204 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1208 for r in repos {
1209 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
1210 }
1211
1212 for r in repos {
1213 let label = if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
1214 match r.repo.parent().and_then(|p| p.file_name()) {
1215 Some(parent) => format!("{} ({})", r.name, parent.to_string_lossy()),
1216 None => r.name.clone(),
1217 }
1218 } else {
1219 r.name.clone()
1220 };
1221 let _ = writeln!(
1222 s,
1223 " {} {} runs {:.0}% completion",
1224 label,
1225 r.stats.totals.runs,
1226 r.stats.totals.completion_rate()
1227 );
1228 }
1229 s
1230}
1231
1232pub fn stats(stats: &Stats) -> String {
1234 let t = &stats.totals;
1235 let mut s = String::new();
1236 let _ = writeln!(s, "{}", bold("runs"));
1237 let _ = writeln!(
1238 s,
1239 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1240 t.runs,
1241 t.merged,
1242 t.ready,
1243 t.blocked,
1244 t.failed,
1245 t.completion_rate()
1246 );
1247 if t.tallied > 0 {
1248 let _ = writeln!(
1249 s,
1250 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1251 {} of those changed a mind, {} converged to unanimous",
1252 t.tallied,
1253 t.split,
1254 t.split_rate(),
1255 t.deliberated,
1256 t.minds_changed,
1257 t.converged
1258 );
1259 }
1260
1261 if !stats.retired_hidden.is_empty() {
1262 let n = stats.retired_hidden.len();
1263 let _ = writeln!(
1264 s,
1265 "{n} retired agent{} hidden: {} (magi stats --all to show)\n",
1266 if n == 1 { "" } else { "s" },
1267 stats.retired_hidden.join(", ")
1268 );
1269 }
1270
1271 if !stats.agents.is_empty() {
1272 let _ = writeln!(
1273 s,
1274 "\n{}",
1275 bold("implementation (relative, on this workload)")
1276 );
1277 let _ = writeln!(
1278 s,
1279 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1280 "agent", "won", "entered", "rate", "empty"
1281 );
1282 for a in &stats.agents {
1283 let _ = writeln!(
1284 s,
1285 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
1286 a.agent,
1287 a.wins,
1288 a.entered,
1289 a.win_rate(),
1290 a.empty
1291 );
1292 }
1293 }
1294
1295 if !stats.reviewers.is_empty() {
1296 let _ = writeln!(s, "\n{}", bold("review"));
1297 let _ = writeln!(
1298 s,
1299 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1300 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1301 );
1302 for r in &stats.reviewers {
1303 let _ = writeln!(
1304 s,
1305 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1306 r.agent,
1307 r.rounds,
1308 r.submitted,
1309 r.adopted_per_round(),
1310 r.precision(),
1311 r.unique_rate(),
1312 r.timeout_rate()
1313 );
1314 }
1315 }
1316
1317 if !stats.advisors.is_empty() {
1318 let _ = writeln!(
1319 s,
1320 "\n{} {}",
1321 bold("design deliberation"),
1322 dim(
1323 "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1324 )
1325 );
1326 let _ = writeln!(
1327 s,
1328 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1329 "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1330 );
1331 for a in &stats.advisors {
1332 let _ = writeln!(
1333 s,
1334 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1335 a.agent,
1336 a.seated,
1337 a.proposed,
1338 a.strong,
1339 a.faint,
1340 a.absent,
1341 a.reflection_rate()
1342 );
1343 }
1344 }
1345
1346 if stats.release_bumps.merged > 0 {
1347 let b = &stats.release_bumps;
1348 let _ = writeln!(s, "\n{}", bold("release bumps"));
1349 let _ = writeln!(
1350 s,
1351 " {} of {} merged run(s) recorded a bump ({:.0}%)",
1352 b.recorded,
1353 b.merged,
1354 b.coverage_rate()
1355 );
1356 let _ = writeln!(
1357 s,
1358 " {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1359 b.pr_opened,
1360 b.automerge_enabled,
1361 b.automerge_rate(),
1362 b.merged_directly
1363 );
1364 let _ = writeln!(
1365 s,
1366 " {} clean, {} needed a human ({:.0}%)",
1367 b.clean(),
1368 b.needs_attention,
1369 b.attention_rate()
1370 );
1371 }
1372
1373 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1374 let _ = writeln!(s, "\n{}", bold("verification"));
1375 let _ = writeln!(
1376 s,
1377 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1378 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1379 stats.e2e.rounds,
1380 stats.e2e.failures,
1381 stats.e2e.sole_detections,
1382 stats.e2e.sole_rate(),
1383 stats.e2e.deferred
1384 );
1385 }
1386
1387 if !stats.nodes.is_empty() {
1388 let _ = writeln!(
1389 s,
1390 "\n{}",
1391 bold("node durations (first to last event, per run)")
1392 );
1393 let max_mean = stats
1394 .nodes
1395 .iter()
1396 .map(|n| n.mean_secs())
1397 .fold(0.0_f64, f64::max);
1398 for n in &stats.nodes {
1399 let unmeasured = if n.single > 0 {
1400 format!(" {}", dim(&format!("{} unmeasured", n.single)))
1401 } else {
1402 String::new()
1403 };
1404 let _ = writeln!(
1405 s,
1406 " {:<14}{:>6} runs{:>8} mean{:>8} max {}{unmeasured}",
1407 n.node,
1408 n.runs,
1409 fmt_secs(n.mean_secs().round() as i64),
1410 fmt_secs(n.max_secs),
1411 bar(n.mean_secs(), max_mean, 20)
1412 );
1413 }
1414 }
1415 s
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420 use super::*;
1421 use crate::config::Config;
1422 use crate::run::{
1423 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1424 Tally,
1425 };
1426 use std::collections::BTreeMap;
1427 use std::path::PathBuf;
1428 use std::sync::{Mutex, MutexGuard};
1429
1430 static SERIAL: Mutex<()> = Mutex::new(());
1432
1433 fn plain() -> MutexGuard<'static, ()> {
1434 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1435 set_color(false);
1436 guard
1437 }
1438
1439 fn state() -> RunState {
1440 crate::run::pin_test_home();
1445 let mut s = RunState::new(
1446 PathBuf::from("/repo"),
1447 "main".to_owned(),
1448 "abcdef1234".to_owned(),
1449 "add retries to the uploader".to_owned(),
1450 Config::default(),
1451 );
1452 s.candidates = vec![Candidate {
1453 index: 0,
1454 label: 'A',
1455 agent: "opus".to_owned(),
1456 branch: "magi/x/A".to_owned(),
1457 worktree: PathBuf::from("/wt/A"),
1458 summary: String::new(),
1459 stat: String::new(),
1460 files: 3,
1461 commits: 2,
1462 empty: false,
1463 failed: None,
1464 verified_noop: None,
1465 duration_ms: 42_000,
1466 folded: false,
1467 }];
1468 s.tally = Some(Tally {
1469 first_choice: BTreeMap::from([('A', 3)]),
1470 borda: BTreeMap::new(),
1471 winner: 'A',
1472 rankings: 3,
1473 unanimous_initial: true,
1474 deliberated: false,
1475 changed_votes: 0,
1476 unanimous_final: true,
1477 tie_break: None,
1478 judges: 3,
1479 present: 3,
1480 quorum: 2,
1481 met_quorum: true,
1482 uncontested: None,
1483 });
1484 s
1485 }
1486
1487 #[test]
1488 fn run_report_names_the_winner_and_its_author() {
1489 let _guard = plain();
1490 let text = run(&state());
1491 assert!(text.contains("<- winner"), "{text}");
1492 assert!(text.contains("opus"));
1493 assert!(text.contains("3 files, 2 commits"));
1494 assert!(text.contains("winner A"));
1495 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1496 }
1497
1498 #[test]
1499 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1500 let _guard = plain();
1506 let mut s = state();
1507 s.candidates[0].summary =
1508 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1509 .to_owned();
1510 let text = run(&s);
1511 assert!(
1512 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1513 "{text}"
1514 );
1515 }
1516
1517 #[test]
1518 fn a_verified_noop_run_does_not_read_as_a_failure() {
1519 let _guard = plain();
1523 let mut s = state();
1524 s.status = RunStatus::VerifiedNoop;
1525 s.tally = None;
1526 s.candidates = vec![Candidate {
1527 index: 0,
1528 label: 'A',
1529 agent: "opus".to_owned(),
1530 branch: "magi/x/A".to_owned(),
1531 worktree: PathBuf::from("/wt/A"),
1532 summary: String::new(),
1533 stat: String::new(),
1534 files: 0,
1535 commits: 0,
1536 empty: true,
1537 failed: None,
1538 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1539 duration_ms: 9_000,
1540 folded: false,
1541 }];
1542 let text = run(&s);
1543 assert!(
1544 text.contains("agent-verified no-op"),
1545 "the status and the candidate flag must both say so: {text}"
1546 );
1547 assert!(
1548 text.contains("already fixed by b32cfc4"),
1549 "the evidence itself must be readable, not just the verdict: {text}"
1550 );
1551 assert!(
1552 !text.to_lowercase().contains("failed"),
1553 "a verified no-op must never read as the failure it is not: {text}"
1554 );
1555 }
1556
1557 #[test]
1558 fn colour_is_emitted_only_when_enabled() {
1559 let _guard = plain();
1560 set_color(true);
1561 let coloured = run(&state());
1562 set_color(false);
1563 let plain = run(&state());
1564 assert!(coloured.contains('\x1b'));
1565 assert!(!plain.contains('\x1b'));
1566 assert!(coloured.len() > plain.len());
1567 }
1568
1569 #[test]
1570 fn list_line_is_single_line() {
1571 let _guard = plain();
1572 let l = line(&state());
1573 assert_eq!(l.lines().count(), 1);
1574 assert!(l.contains("add retries"));
1575 assert!(l.contains("win A (opus)"));
1576 }
1577
1578 #[test]
1579 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1580 let _guard = plain();
1581 let mut s = state();
1582 s.tally = Some(Tally {
1583 first_choice: BTreeMap::from([('A', 0)]),
1584 borda: BTreeMap::new(),
1585 winner: 'A',
1586 rankings: 0,
1587 unanimous_initial: false,
1588 deliberated: false,
1589 changed_votes: 0,
1590 unanimous_final: false,
1591 tie_break: None,
1592 judges: 0,
1593 present: 0,
1594 quorum: 0,
1595 met_quorum: true,
1596 uncontested: Some(
1597 "only candidate A produced a usable change; no panel was asked".to_owned(),
1598 ),
1599 });
1600 let text = run(&s);
1601 assert!(
1602 !text.contains("0/3"),
1603 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1604 );
1605 assert!(!text.contains("no usable ranking"), "{text}");
1606 assert!(!text.contains("still split"), "{text}");
1607 assert!(!text.contains("BELOW QUORUM"), "{text}");
1608 assert!(
1609 text.contains("not needed"),
1610 "the report must say judging was skipped, not silent: {text}"
1611 );
1612 assert!(text.contains("winner A"));
1613 }
1614
1615 #[test]
1616 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1617 let _guard = plain();
1618 let mut s = state();
1619 s.tally = Some(Tally {
1620 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1621 borda: BTreeMap::new(),
1622 winner: 'A',
1623 rankings: 1,
1624 unanimous_initial: false,
1625 deliberated: false,
1626 changed_votes: 0,
1627 unanimous_final: false,
1628 tie_break: None,
1629 judges: 3,
1630 present: 1,
1631 quorum: 2,
1632 met_quorum: false,
1633 uncontested: None,
1634 });
1635 let text = run(&s);
1636 assert!(text.contains("1/3"), "{text}");
1637 assert!(
1638 text.contains("BELOW QUORUM"),
1639 "a real collapse must still be flagged: {text}"
1640 );
1641 assert!(
1642 !text.contains("not needed"),
1643 "a collapsed panel must not be described as one that was never asked: {text}"
1644 );
1645 }
1646
1647 #[test]
1648 fn a_mode_none_merge_does_not_read_as_landed() {
1649 let _guard = plain();
1650 let mut s = state();
1651 s.merge = Some(MergeOutcome {
1652 mode: crate::config::MergeMode::None,
1653 ok: true,
1654 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1655 empty: false,
1656 });
1657 let text = run(&s);
1658 assert!(
1659 !text.contains(" ok"),
1660 "mode none must not be shown as a landed merge: {text}"
1661 );
1662 assert!(text.contains("not landed"), "{text}");
1663 assert!(
1664 text.contains("branch magi/x/A"),
1665 "the report must say what's left behind: {text}"
1666 );
1667 assert!(
1668 text.contains("rebase"),
1669 "the report must point at the hand-landing steps: {text}"
1670 );
1671 assert!(
1672 !text.contains("placeholder subject"),
1673 "the default merge style is `merge`, which never inherits a \
1674 placeholder subject, so the squash caveat must not appear: {text}"
1675 );
1676 }
1677
1678 #[test]
1679 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1680 let _guard = plain();
1681 let mut s = state();
1682 s.config.merge.style = MergeStyle::Squash;
1683 s.merge = Some(MergeOutcome {
1684 mode: crate::config::MergeMode::None,
1685 ok: true,
1686 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1687 retries\""
1688 .to_owned(),
1689 empty: false,
1690 });
1691 let text = run(&s);
1692 assert!(
1693 text.contains("placeholder subject"),
1694 "a squash-style manual merge must warn about the missing message: {text}"
1695 );
1696 assert!(text.contains("--squash"), "{text}");
1697 }
1698
1699 #[test]
1700 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1701 let _guard = plain();
1702 let mut s = state();
1703 s.status = RunStatus::Ready;
1704 s.merge = Some(MergeOutcome {
1705 mode: crate::config::MergeMode::None,
1706 ok: true,
1707 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1708 empty: false,
1709 });
1710
1711 let list = line(&s);
1712 assert!(
1713 !list.contains(" ready "),
1714 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1715 );
1716 assert!(list.contains("no-op by design"), "{list}");
1717
1718 let full = run(&s);
1719 assert!(
1720 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1721 "the header line of `magi show` must not say plain ready either: {full}"
1722 );
1723 assert!(full.contains("no-op by design"), "{full}");
1724 }
1725
1726 #[test]
1727 fn an_ordinary_ready_run_still_reads_as_ready() {
1728 let _guard = plain();
1729 let mut s = state();
1730 s.status = RunStatus::Ready;
1731 s.merge = Some(MergeOutcome {
1735 mode: crate::config::MergeMode::Pr,
1736 ok: false,
1737 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1738 empty: false,
1739 });
1740
1741 let list = line(&s);
1742 assert!(list.contains("ready"), "{list}");
1743 assert!(!list.contains("no-op by design"), "{list}");
1744 }
1745
1746 #[test]
1747 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1748 let _guard = plain();
1749 let mut s = state();
1750 s.tally = Some(Tally {
1751 first_choice: BTreeMap::from([('A', 0)]),
1752 borda: BTreeMap::new(),
1753 winner: 'A',
1754 rankings: 0,
1755 unanimous_initial: false,
1756 deliberated: false,
1757 changed_votes: 0,
1758 unanimous_final: false,
1759 tie_break: None,
1760 judges: 0,
1761 present: 0,
1762 quorum: 0,
1763 met_quorum: true,
1764 uncontested: Some("only candidate A produced a usable change".to_owned()),
1765 });
1766 let l = line(&s);
1767 assert!(
1768 !l.contains("judges") && !l.contains("quorum"),
1769 "an uncontested run must not carry the same badge a short panel gets: {l}"
1770 );
1771 }
1772
1773 #[test]
1774 fn long_instructions_are_elided() {
1775 let _guard = plain();
1776 let mut s = state();
1777 s.instruction = "x".repeat(200);
1778 assert!(line(&s).contains('…'));
1779 }
1780
1781 #[test]
1782 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1783 let _guard = plain();
1784 let mut lost = state();
1785 lost.reviews = vec![ReviewRound {
1786 round: 1,
1787 head: "abc1234".to_owned(),
1788 verified_head: None,
1789 verified_at: None,
1790 reviews: Vec::new(),
1791 e2e: Vec::new(),
1792 verify_retried: false,
1793 e2e_deferred: false,
1794 e2e_defer_reason: None,
1795 fix: Some(FixRecord {
1796 agent: "opus".to_owned(),
1797 addressed: Vec::new(),
1798 rejected: Vec::new(),
1799 notes: String::new(),
1800 committed: true,
1801 failed: Some("timed out".to_owned()),
1802 duration_ms: 0,
1803 continuation: None,
1804 }),
1805 blocking: 3,
1806 answered: 0,
1807 expected: 0,
1808 clean: false,
1809 progressed: false,
1810 vote_split: false,
1811 reconsideration: Vec::new(),
1812 verdict: None,
1813 }];
1814 let text = run(&lost);
1815 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1816 assert!(
1817 !text.contains("0 addressed"),
1818 "a lost report must never read as `0 addressed`: {text}"
1819 );
1820
1821 let mut rejected_all = state();
1822 rejected_all.reviews = vec![ReviewRound {
1823 round: 1,
1824 head: "abc1234".to_owned(),
1825 verified_head: None,
1826 verified_at: None,
1827 reviews: Vec::new(),
1828 e2e: Vec::new(),
1829 verify_retried: false,
1830 e2e_deferred: false,
1831 e2e_defer_reason: None,
1832 fix: Some(FixRecord {
1833 agent: "opus".to_owned(),
1834 addressed: Vec::new(),
1835 rejected: Vec::new(),
1836 notes: String::new(),
1837 committed: true,
1838 failed: None,
1839 duration_ms: 0,
1840 continuation: None,
1841 }),
1842 blocking: 3,
1843 answered: 0,
1844 expected: 0,
1845 clean: false,
1846 progressed: false,
1847 vote_split: false,
1848 reconsideration: Vec::new(),
1849 verdict: None,
1850 }];
1851 let text2 = run(&rejected_all);
1852 assert!(
1853 text2.contains("0 addressed / 0 rejected"),
1854 "a round the fixer actually reported on keeps the count: {text2}"
1855 );
1856 }
1857
1858 #[test]
1859 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1860 use crate::run::ReviewRevoteRecord;
1861 use crate::verdict::ReviewVote;
1862
1863 let _guard = plain();
1864 let mut s = state();
1865 s.reviews = vec![ReviewRound {
1866 round: 1,
1867 head: "abc1234".to_owned(),
1868 verified_head: None,
1869 verified_at: None,
1870 reviews: vec![
1871 ReviewRecord {
1872 attempts: 0,
1873 reviewer: 1,
1874 agent: "alpha".to_owned(),
1875 summary: String::new(),
1876 findings: Vec::new(),
1877 vote: Some(ReviewVote::Approve),
1878 failed: None,
1879 duration_ms: 0,
1880 },
1881 ReviewRecord {
1882 attempts: 0,
1883 reviewer: 2,
1884 agent: "beta".to_owned(),
1885 summary: String::new(),
1886 findings: Vec::new(),
1887 vote: Some(ReviewVote::Reject),
1888 failed: None,
1889 duration_ms: 0,
1890 },
1891 ],
1892 e2e: Vec::new(),
1893 verify_retried: false,
1894 e2e_deferred: false,
1895 e2e_defer_reason: None,
1896 fix: None,
1897 blocking: 0,
1898 answered: 2,
1899 expected: 2,
1900 clean: false,
1901 progressed: false,
1902 vote_split: true,
1903 reconsideration: vec![ReviewRevoteRecord {
1904 reviewer: 2,
1905 agent: "beta".to_owned(),
1906 vote: Some(ReviewVote::ApproveWithFindings),
1907 reason: "the other seat's read holds up".to_owned(),
1908 failed: None,
1909 }],
1910 verdict: Some(ReviewVote::ApproveWithFindings),
1911 }];
1912 let text = run(&s);
1913 assert!(text.contains("review-1 vote"), "{text}");
1914 assert!(text.contains("review-2 vote"), "{text}");
1915 assert!(text.contains("panel split"), "{text}");
1916 assert!(text.contains("reconsideration"), "{text}");
1917 assert!(text.contains("the other seat's read holds up"), "{text}");
1918 }
1919
1920 #[test]
1921 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1922 let _guard = plain();
1928 let mut s = state();
1929 s.reviews = vec![ReviewRound {
1930 round: 1,
1931 head: "abc1234".to_owned(),
1932 verified_head: None,
1933 verified_at: None,
1934 reviews: vec![
1935 ReviewRecord {
1936 attempts: 0,
1937 reviewer: 1,
1938 agent: "alpha".to_owned(),
1939 summary: String::new(),
1940 findings: Vec::new(),
1941 vote: None,
1942 failed: None,
1943 duration_ms: 0,
1944 },
1945 ReviewRecord {
1946 attempts: 0,
1947 reviewer: 2,
1948 agent: "beta".to_owned(),
1949 summary: String::new(),
1950 findings: Vec::new(),
1951 vote: None,
1952 failed: Some("agent timed out".to_owned()),
1953 duration_ms: 0,
1954 },
1955 ],
1956 e2e: Vec::new(),
1957 verify_retried: false,
1958 e2e_deferred: false,
1959 e2e_defer_reason: None,
1960 fix: Some(FixRecord {
1961 agent: "opus".to_owned(),
1962 addressed: Vec::new(),
1963 rejected: Vec::new(),
1964 notes: String::new(),
1965 committed: true,
1966 failed: Some("timed out".to_owned()),
1967 duration_ms: 0,
1968 continuation: None,
1969 }),
1970 blocking: 0,
1971 answered: 1,
1972 expected: 2,
1973 clean: false,
1974 progressed: true,
1975 vote_split: false,
1976 reconsideration: Vec::new(),
1977 verdict: None,
1978 }];
1979 let text = run(&s);
1980 assert!(text.contains("incomplete"), "{text}");
1981 assert!(text.contains("1/2 reviewers answered"), "{text}");
1982 assert!(text.contains("review-2: agent timed out"), "{text}");
1983 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1984 assert!(
1985 !text.contains("clean"),
1986 "a round missing half its panel must never render as clean: {text}"
1987 );
1988 }
1989
1990 #[test]
1991 fn a_build_failure_is_not_reported_as_a_test_failure() {
1992 let _guard = plain();
1993 let mut s = state();
1994 s.reviews = vec![ReviewRound {
1995 round: 1,
1996 head: "abc1234".to_owned(),
1997 verified_head: None,
1998 verified_at: None,
1999 reviews: Vec::new(),
2000 e2e: vec![CommandOutcome {
2001 command: "cargo test".to_owned(),
2002 code: Some(1),
2003 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2004 duration_ms: 100,
2005 resource_blocked: false,
2006 }],
2007 verify_retried: true,
2008 e2e_deferred: false,
2009 e2e_defer_reason: None,
2010 fix: None,
2011 blocking: 0,
2012 answered: 0,
2013 expected: 0,
2014 clean: false,
2015 progressed: false,
2016 vote_split: false,
2017 reconsideration: Vec::new(),
2018 verdict: None,
2019 }];
2020 let text = run(&s);
2021 assert!(text.contains("could not run"), "{text}");
2022 assert!(text.contains("retried once"), "{text}");
2023 assert!(!text.contains("e2e RED"), "{text}");
2024 }
2025
2026 #[test]
2027 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2028 let _guard = plain();
2029 let mut s = state();
2030 s.reviews = vec![ReviewRound {
2031 round: 1,
2032 head: "abc1234".to_owned(),
2033 verified_head: None,
2034 verified_at: None,
2035 reviews: Vec::new(),
2036 e2e: vec![CommandOutcome {
2037 command: "(waiting for the shared build cache)".to_owned(),
2038 code: None,
2039 output_tail: "held by run x node e2e seat e2e".to_owned(),
2040 duration_ms: 100,
2041 resource_blocked: true,
2042 }],
2043 verify_retried: false,
2044 e2e_deferred: false,
2045 e2e_defer_reason: None,
2046 fix: None,
2047 blocking: 0,
2048 answered: 0,
2049 expected: 0,
2050 clean: false,
2051 progressed: false,
2052 vote_split: false,
2053 reconsideration: Vec::new(),
2054 verdict: None,
2055 }];
2056 let text = run(&s);
2057 assert!(text.contains("shared build cache unavailable"), "{text}");
2058 assert!(!text.contains("e2e RED"), "{text}");
2059 assert!(!text.contains("build/link failure"), "{text}");
2060 }
2061
2062 #[test]
2063 fn a_round_with_more_than_one_e2e_command_names_each_one() {
2064 let _guard = plain();
2069 let mut s = state();
2070 s.reviews = vec![ReviewRound {
2071 round: 1,
2072 head: "abc1234".to_owned(),
2073 verified_head: Some("abc1234".to_owned()),
2074 verified_at: Some(jiff::Timestamp::now()),
2075 reviews: Vec::new(),
2076 e2e: vec![
2077 CommandOutcome {
2078 command: "cargo test --locked --all-targets".to_owned(),
2079 code: Some(0),
2080 output_tail: String::new(),
2081 duration_ms: 0,
2082 resource_blocked: false,
2083 },
2084 CommandOutcome {
2085 command: "cargo make check".to_owned(),
2086 code: Some(1),
2087 output_tail: "clippy: unused import".to_owned(),
2088 duration_ms: 0,
2089 resource_blocked: false,
2090 },
2091 ],
2092 verify_retried: false,
2093 e2e_deferred: false,
2094 e2e_defer_reason: None,
2095 fix: None,
2096 blocking: 0,
2097 answered: 0,
2098 expected: 0,
2099 clean: false,
2100 progressed: false,
2101 vote_split: false,
2102 reconsideration: Vec::new(),
2103 verdict: None,
2104 }];
2105 let text = run(&s);
2106 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2107 assert!(text.contains("cargo make check"), "{text}");
2108 assert!(text.contains("clippy: unused import"), "{text}");
2109 }
2110
2111 #[test]
2112 fn a_declined_finding_shows_its_reason() {
2113 use crate::verdict::{Finding, Rejection, Severity};
2114
2115 let _guard = plain();
2116 let mut s = state();
2117 s.status = RunStatus::Ready;
2118 s.reviews = vec![ReviewRound {
2119 round: 1,
2120 head: "deadbee".to_owned(),
2121 verified_head: None,
2122 verified_at: None,
2123 reviews: vec![ReviewRecord {
2124 attempts: 0,
2125 reviewer: 1,
2126 agent: "alpha".to_owned(),
2127 summary: String::new(),
2128 findings: vec![Finding {
2129 id: "R1-1-1".to_owned(),
2130 severity: Severity::Major,
2131 file: None,
2132 line: None,
2133 title: "still open".to_owned(),
2134 detail: String::new(),
2135 }],
2136 vote: None,
2137 failed: None,
2138 duration_ms: 0,
2139 }],
2140 e2e: vec![CommandOutcome {
2141 command: "cargo test".to_owned(),
2142 code: Some(0),
2143 output_tail: String::new(),
2144 duration_ms: 0,
2145 resource_blocked: false,
2146 }],
2147 verify_retried: false,
2148 e2e_deferred: false,
2149 e2e_defer_reason: None,
2150 fix: Some(FixRecord {
2151 agent: "alpha".to_owned(),
2152 addressed: Vec::new(),
2153 rejected: vec![Rejection {
2154 id: "R1-1-2".to_owned(),
2155 why: "cannot be triggered from any caller".to_owned(),
2156 }],
2157 notes: String::new(),
2158 committed: true,
2159 failed: None,
2160 duration_ms: 0,
2161 continuation: None,
2162 }),
2163 blocking: 1,
2164 answered: 1,
2165 expected: 1,
2166 clean: false,
2167 progressed: true,
2168 vote_split: false,
2169 reconsideration: Vec::new(),
2170 verdict: None,
2171 }];
2172
2173 let text = run(&s);
2174 assert!(text.contains("R1-1-2"), "{text}");
2175 assert!(text.contains("cannot be triggered"), "{text}");
2176 assert!(text.contains("still open"), "{text}");
2177 assert!(
2178 text.contains("handed off"),
2179 "a mergeable run with an open round must say so: {text}"
2180 );
2181 }
2182
2183 #[test]
2184 fn a_failing_gate_command_shows_its_output() {
2185 let _guard = plain();
2186 let mut s = state();
2187 s.status = RunStatus::Blocked;
2188 s.gate = vec![CommandOutcome {
2189 command: "cargo make check".to_owned(),
2190 code: Some(101),
2191 output_tail: "error[E0308]: mismatched types".to_owned(),
2192 duration_ms: 0,
2193 resource_blocked: false,
2194 }];
2195 s.gate_ran = true;
2196
2197 let text = run(&s);
2198 assert!(text.contains("mismatched types"), "{text}");
2199 }
2200
2201 #[test]
2202 fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
2203 let _guard = plain();
2204 let mut s = state();
2205 s.status = RunStatus::Ready;
2206 s.gate_ran = true;
2207 assert!(s.gate.is_empty());
2208
2209 let text = run(&s);
2210 assert!(
2211 text.contains("gate") && text.contains("no gate commands configured"),
2212 "a run gated on nothing must say so, not read as if the gate never ran: {text}"
2213 );
2214 }
2215
2216 #[test]
2217 fn a_gate_that_has_not_run_yet_shows_nothing() {
2218 let _guard = plain();
2219 let s = state();
2220 assert!(!s.gate_ran);
2221 assert!(s.gate.is_empty());
2222
2223 let text = run(&s);
2224 assert!(
2225 !text.contains("no gate commands configured"),
2226 "an unattempted gate must not be shown as a pass: {text}"
2227 );
2228 }
2229
2230 #[test]
2231 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2232 let _guard = plain();
2233 let mut s = state();
2234 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2235 let text = active_seats(&s, Liveness::Live);
2236 assert!(text.contains("running now"));
2237 assert!(text.contains("judge-2"));
2238 assert!(text.contains("judge"));
2239 assert!(!text.contains("no live daemon"), "{text}");
2240 assert!(!text.contains("could not be confirmed"), "{text}");
2241 }
2242
2243 #[test]
2244 fn active_seats_flags_a_leftover_from_a_dead_process() {
2245 let _guard = plain();
2246 let mut s = state();
2247 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2248 let text = active_seats(&s, Liveness::Dead);
2249 assert!(
2250 text.contains("no live daemon"),
2251 "a stale entry must not read as running: {text}"
2252 );
2253 }
2254
2255 #[test]
2256 fn superseded_notice_names_the_run_that_replaced_it() {
2257 let _guard = plain();
2258 let text = superseded_notice(Some("bbbb"));
2259 assert!(text.contains("SUPERSEDED"), "{text}");
2260 assert!(text.contains("bbbb"), "{text}");
2261 }
2262
2263 #[test]
2264 fn superseded_notice_is_silent_for_the_latest_attempt() {
2265 let _guard = plain();
2266 assert_eq!(superseded_notice(None), "");
2267 }
2268
2269 #[test]
2270 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2271 let _guard = plain();
2272 let mut s = state();
2273 s.status = RunStatus::Reviewing;
2274 let text = line_with_liveness(&s, Liveness::Dead);
2275 assert!(text.contains("STALE"), "{text}");
2276 assert!(text.contains("resume required"), "{text}");
2277 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2278 }
2279
2280 #[test]
2285 fn active_seats_reports_uncertainty_without_claiming_death() {
2286 let _guard = plain();
2287 let mut s = state();
2288 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2289 let text = active_seats(&s, Liveness::Unknown);
2290 assert!(
2291 text.contains("could not be confirmed"),
2292 "an unproven state must read as uncertain, not dead: {text}"
2293 );
2294 assert!(!text.contains("no live daemon"), "{text}");
2295 }
2296
2297 #[test]
2298 fn active_seats_is_empty_when_nothing_is_running() {
2299 let _guard = plain();
2300 assert_eq!(active_seats(&state(), Liveness::Live), "");
2301 }
2302
2303 #[test]
2309 fn active_seats_shows_a_running_verify_task_and_its_command() {
2310 let _guard = plain();
2311 let mut s = state();
2312 s.task_command(
2313 "e2e",
2314 "verify",
2315 0,
2316 "cargo test",
2317 2,
2318 3,
2319 std::time::Duration::from_secs(600),
2320 );
2321 let text = active_seats(&s, Liveness::Live);
2322 assert!(text.contains("e2e"), "{text}");
2323 assert!(text.contains("(2/3)"), "{text}");
2324 assert!(text.contains("cargo test"), "{text}");
2325 }
2326
2327 #[test]
2328 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2329 let _guard = plain();
2330 assert!(!run(&state()).contains("background jobs"));
2333 }
2334
2335 #[test]
2336 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2337 let _guard = plain();
2338 let mut s = state();
2339 s.config.agents.push(crate::config::AgentSpec {
2340 id: "codex-one".to_owned(),
2341 kind: crate::config::AgentKind::Codex,
2342 model: None,
2343 command: vec!["codex".to_owned()],
2344 extra_args: Vec::new(),
2345 env: BTreeMap::new(),
2346 prompt_delivery: None,
2347 });
2348 let text = run(&s);
2349 assert!(
2350 text.contains("background jobs"),
2351 "a run that could report this must not read the same as one that never could: \
2352 {text}"
2353 );
2354 assert!(text.contains("no completed command evidence yet"));
2355 }
2356
2357 #[test]
2358 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2359 let _guard = plain();
2360 let mut s = state();
2361 s.jobs = vec![
2362 crate::run::JobRecord {
2363 node: "implement".to_owned(),
2364 round: None,
2365 seat: "impl-A".to_owned(),
2366 id: "item49".to_owned(),
2367 description: "cargo test --test graph_cached_gate".to_owned(),
2368 checked_at: jiff::Timestamp::now(),
2369 status: crate::run::JobStatus::Completed,
2370 exit_code: Some(0),
2371 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2372 source: "codex".to_owned(),
2373 },
2374 crate::run::JobRecord {
2375 node: "fix".to_owned(),
2376 round: None,
2377 seat: "impl-A".to_owned(),
2378 id: "item52".to_owned(),
2379 description: "cargo test --test graph_split".to_owned(),
2380 checked_at: jiff::Timestamp::now(),
2381 status: crate::run::JobStatus::Failed,
2382 exit_code: Some(101),
2383 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2384 source: "codex".to_owned(),
2385 },
2386 crate::run::JobRecord {
2387 node: "fix".to_owned(),
2388 round: None,
2389 seat: "impl-A".to_owned(),
2390 id: "item60".to_owned(),
2391 description: "cargo build".to_owned(),
2392 checked_at: jiff::Timestamp::now(),
2393 status: crate::run::JobStatus::Unknown,
2394 exit_code: None,
2395 result_summary: String::new(),
2396 source: "codex".to_owned(),
2397 },
2398 ];
2399 let text = run(&s);
2400 assert!(text.contains("background jobs"));
2401 assert!(text.contains("item49"));
2402 assert!(text.contains("item52"));
2403 assert!(text.contains("item60"));
2404 assert!(text.contains("completed"));
2408 assert!(text.contains("failed"));
2409 assert!(text.contains("unknown"));
2410 assert!(text.contains("adapter coverage"));
2412 }
2413
2414 #[test]
2415 fn a_jobs_own_round_is_shown_when_known() {
2416 let _guard = plain();
2417 let mut s = state();
2418 s.jobs = vec![crate::run::JobRecord {
2419 node: "review".to_owned(),
2420 round: Some(2),
2421 seat: "review-1".to_owned(),
2422 id: "item9".to_owned(),
2423 description: "cargo test --test graph_cached_gate".to_owned(),
2424 checked_at: jiff::Timestamp::now(),
2425 status: crate::run::JobStatus::Completed,
2426 exit_code: Some(0),
2427 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2428 source: "codex".to_owned(),
2429 }];
2430 let text = run(&s);
2431 assert!(
2432 text.contains("round 2"),
2433 "the round this seat's own command ran in must be visible, distinct from magi's \
2434 own recorded verify: {text}"
2435 );
2436 }
2437
2438 #[test]
2439 fn stats_table_renders_without_runs() {
2440 let _guard = plain();
2441 let text = stats(&Stats::default());
2442 assert!(text.contains("0 total"));
2443 assert!(!text.contains("implementation"));
2444 assert!(!text.contains("node durations"));
2445 }
2446
2447 #[test]
2448 fn repo_summary_lists_run_count_and_completion_rate() {
2449 let _guard = plain();
2450 let a = crate::stats::RepoStats {
2451 repo: PathBuf::from("/repos/a"),
2452 name: "a".to_owned(),
2453 stats: Stats {
2454 totals: crate::stats::Totals {
2455 runs: 4,
2456 merged: 2,
2457 ready: 1,
2458 ..Default::default()
2459 },
2460 ..Default::default()
2461 },
2462 };
2463 let text = repo_summary(&[a]);
2464 assert!(text.contains("repos"), "{text}");
2465 assert!(text.contains("a 4 runs 75% completion"), "{text}");
2466 }
2467
2468 #[test]
2469 fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2470 let _guard = plain();
2471 let a = crate::stats::RepoStats {
2472 repo: PathBuf::from("/roots/one/magi"),
2473 name: "magi".to_owned(),
2474 stats: Stats::default(),
2475 };
2476 let b = crate::stats::RepoStats {
2477 repo: PathBuf::from("/roots/two/magi"),
2478 name: "magi".to_owned(),
2479 stats: Stats::default(),
2480 };
2481 let text = repo_summary(&[a, b]);
2482 assert!(text.contains("magi (one)"), "{text}");
2483 assert!(text.contains("magi (two)"), "{text}");
2484 }
2485
2486 #[test]
2487 fn stats_table_renders_advisor_reflection() {
2488 let _guard = plain();
2489 let st = Stats {
2490 advisors: vec![crate::stats::AdvisorStats {
2491 agent: "alpha".to_owned(),
2492 seated: 3,
2493 proposed: 2,
2494 absent: 1,
2495 faint: 1,
2496 strong: 1,
2497 }],
2498 ..Default::default()
2499 };
2500 let text = stats(&st);
2501 assert!(text.contains("design deliberation"));
2502 assert!(
2503 text.contains("approximate"),
2504 "must note the heuristic: {text}"
2505 );
2506 assert!(text.contains("alpha"));
2507 assert!(text.contains("50%"));
2508 }
2509
2510 #[test]
2511 fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2512 let _guard = plain();
2517 let st = Stats {
2518 release_bumps: crate::stats::ReleaseBumpStats {
2519 merged: 3,
2520 ..Default::default()
2521 },
2522 ..Default::default()
2523 };
2524 let text = stats(&st);
2525 assert!(text.contains("release bumps"), "{text}");
2526 assert!(
2527 text.contains("0 of 3 merged run(s) recorded a bump"),
2528 "{text}"
2529 );
2530 }
2531
2532 #[test]
2533 fn stats_table_renders_release_bump_coverage_and_attention() {
2534 let _guard = plain();
2535 let st = Stats {
2536 release_bumps: crate::stats::ReleaseBumpStats {
2537 merged: 4,
2538 recorded: 2,
2539 pr_opened: 2,
2540 automerge_enabled: 1,
2541 merged_directly: 0,
2542 needs_attention: 1,
2543 },
2544 ..Default::default()
2545 };
2546 let text = stats(&st);
2547 assert!(text.contains("release bumps"), "{text}");
2548 assert!(
2549 text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2550 "{text}"
2551 );
2552 assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2553 }
2554
2555 #[test]
2556 fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2557 let _guard = plain();
2558 let st = Stats {
2559 nodes: vec![
2560 crate::stats::NodeDuration {
2561 node: "implement".to_owned(),
2562 runs: 2,
2563 total_secs: 200,
2564 max_secs: 150,
2565 single: 1,
2566 },
2567 crate::stats::NodeDuration {
2568 node: "gate".to_owned(),
2569 runs: 0,
2570 total_secs: 0,
2571 max_secs: 0,
2572 single: 3,
2573 },
2574 ],
2575 ..Default::default()
2576 };
2577 let text = stats(&st);
2578 assert!(text.contains("node durations"));
2579 assert!(text.contains("implement"));
2580 assert!(text.contains("2 runs"));
2581 assert!(text.contains('█'), "the leader must draw a bar: {text}");
2582 assert!(text.contains("1 unmeasured"));
2583 assert!(text.contains("gate"));
2584 assert!(text.contains("3 unmeasured"));
2585 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2586 }
2587}