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 let has_competitive = stats.agents.iter().any(|a| a.competitive_entered > 0);
1288 if has_competitive {
1289 let mut comp_agents = stats.agents.clone();
1290 comp_agents.retain(|a| a.competitive_entered > 0);
1291 comp_agents.sort_by(|a, b| {
1292 let a_tier = if a.competitive_entered < 10 { 1 } else { 0 };
1293 let b_tier = if b.competitive_entered < 10 { 1 } else { 0 };
1294 a_tier
1295 .cmp(&b_tier)
1296 .then(
1297 b.competitive_win_rate()
1298 .total_cmp(&a.competitive_win_rate()),
1299 )
1300 .then(b.competitive_entered.cmp(&a.competitive_entered))
1301 .then(a.agent.cmp(&b.agent))
1302 });
1303
1304 let _ = writeln!(
1305 s,
1306 "\n{}",
1307 bold("implementation (competitive runs, 2+ candidates)")
1308 );
1309 let _ = writeln!(
1310 s,
1311 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1312 "agent", "won", "entered", "rate", "empty"
1313 );
1314 for a in &comp_agents {
1315 let low_n_marker = if a.competitive_entered < 10 {
1316 format!(" {}", dim("low n"))
1317 } else {
1318 String::new()
1319 };
1320 let _ = writeln!(
1321 s,
1322 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}{}",
1323 a.agent,
1324 a.competitive_wins,
1325 a.competitive_entered,
1326 a.competitive_win_rate(),
1327 a.empty,
1328 low_n_marker
1329 );
1330 }
1331 }
1332
1333 let has_solo = stats.agents.iter().any(|a| a.solo_entered > 0);
1334 if has_solo {
1335 let mut solo_agents = stats.agents.clone();
1336 solo_agents.retain(|a| a.solo_entered > 0);
1337 solo_agents.sort_by(|a, b| {
1338 let a_tier = if a.solo_entered < 10 { 1 } else { 0 };
1339 let b_tier = if b.solo_entered < 10 { 1 } else { 0 };
1340 a_tier
1341 .cmp(&b_tier)
1342 .then(b.solo_adoption_rate().total_cmp(&a.solo_adoption_rate()))
1343 .then(b.solo_entered.cmp(&a.solo_entered))
1344 .then(a.agent.cmp(&b.agent))
1345 });
1346
1347 let _ = writeln!(
1348 s,
1349 "\n{}",
1350 bold("implementation (solo runs, completion/adoption rate)")
1351 );
1352 let _ = writeln!(
1353 s,
1354 " {:<14}{:>8}{:>8}{:>8}",
1355 "agent", "adopted", "entered", "rate"
1356 );
1357 for a in &solo_agents {
1358 let low_n_marker = if a.solo_entered < 10 {
1359 format!(" {}", dim("low n"))
1360 } else {
1361 String::new()
1362 };
1363 let _ = writeln!(
1364 s,
1365 " {:<14}{:>8}{:>8}{:>7.0}%{}",
1366 a.agent,
1367 a.solo_adopted,
1368 a.solo_entered,
1369 a.solo_adoption_rate(),
1370 low_n_marker
1371 );
1372 }
1373 }
1374
1375 if !stats.reviewers.is_empty() {
1376 let _ = writeln!(s, "\n{}", bold("review"));
1377 let _ = writeln!(
1378 s,
1379 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1380 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1381 );
1382 for r in &stats.reviewers {
1383 let _ = writeln!(
1384 s,
1385 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1386 r.agent,
1387 r.rounds,
1388 r.submitted,
1389 r.adopted_per_round(),
1390 r.precision(),
1391 r.unique_rate(),
1392 r.timeout_rate()
1393 );
1394 }
1395 }
1396
1397 if !stats.advisors.is_empty() {
1398 let _ = writeln!(
1399 s,
1400 "\n{} {}",
1401 bold("design deliberation"),
1402 dim(
1403 "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1404 )
1405 );
1406 let _ = writeln!(
1407 s,
1408 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1409 "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1410 );
1411 for a in &stats.advisors {
1412 let _ = writeln!(
1413 s,
1414 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1415 a.agent,
1416 a.seated,
1417 a.proposed,
1418 a.strong,
1419 a.faint,
1420 a.absent,
1421 a.reflection_rate()
1422 );
1423 }
1424 }
1425
1426 if stats.release_bumps.merged > 0 {
1427 let b = &stats.release_bumps;
1428 let _ = writeln!(s, "\n{}", bold("release bumps"));
1429 let _ = writeln!(
1430 s,
1431 " {} of {} merged run(s) recorded a bump ({:.0}%)",
1432 b.recorded,
1433 b.merged,
1434 b.coverage_rate()
1435 );
1436 let _ = writeln!(
1437 s,
1438 " {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1439 b.pr_opened,
1440 b.automerge_enabled,
1441 b.automerge_rate(),
1442 b.merged_directly
1443 );
1444 let _ = writeln!(
1445 s,
1446 " {} clean, {} needed a human ({:.0}%)",
1447 b.clean(),
1448 b.needs_attention,
1449 b.attention_rate()
1450 );
1451 }
1452
1453 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1454 let _ = writeln!(s, "\n{}", bold("verification"));
1455 let _ = writeln!(
1456 s,
1457 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1458 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1459 stats.e2e.rounds,
1460 stats.e2e.failures,
1461 stats.e2e.sole_detections,
1462 stats.e2e.sole_rate(),
1463 stats.e2e.deferred
1464 );
1465 }
1466
1467 if !stats.nodes.is_empty() {
1468 let _ = writeln!(
1469 s,
1470 "\n{}",
1471 bold("node durations (first to last event, per run)")
1472 );
1473 let max_mean = stats
1474 .nodes
1475 .iter()
1476 .map(|n| n.mean_secs())
1477 .fold(0.0_f64, f64::max);
1478 for n in &stats.nodes {
1479 let unmeasured = if n.single > 0 {
1480 format!(" {}", dim(&format!("{} unmeasured", n.single)))
1481 } else {
1482 String::new()
1483 };
1484 let _ = writeln!(
1485 s,
1486 " {:<14}{:>6} runs{:>8} mean{:>8} max {}{unmeasured}",
1487 n.node,
1488 n.runs,
1489 fmt_secs(n.mean_secs().round() as i64),
1490 fmt_secs(n.max_secs),
1491 bar(n.mean_secs(), max_mean, 20)
1492 );
1493 }
1494 }
1495 s
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use crate::config::Config;
1502 use crate::run::{
1503 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1504 Tally,
1505 };
1506 use std::collections::BTreeMap;
1507 use std::path::PathBuf;
1508 use std::sync::{Mutex, MutexGuard};
1509
1510 static SERIAL: Mutex<()> = Mutex::new(());
1512
1513 fn plain() -> MutexGuard<'static, ()> {
1514 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1515 set_color(false);
1516 guard
1517 }
1518
1519 fn state() -> RunState {
1520 crate::run::pin_test_home();
1525 let mut s = RunState::new(
1526 PathBuf::from("/repo"),
1527 "main".to_owned(),
1528 "abcdef1234".to_owned(),
1529 "add retries to the uploader".to_owned(),
1530 Config::default(),
1531 );
1532 s.candidates = vec![Candidate {
1533 index: 0,
1534 label: 'A',
1535 agent: "opus".to_owned(),
1536 branch: "magi/x/A".to_owned(),
1537 worktree: PathBuf::from("/wt/A"),
1538 summary: String::new(),
1539 stat: String::new(),
1540 files: 3,
1541 commits: 2,
1542 empty: false,
1543 failed: None,
1544 verified_noop: None,
1545 duration_ms: 42_000,
1546 folded: false,
1547 }];
1548 s.tally = Some(Tally {
1549 first_choice: BTreeMap::from([('A', 3)]),
1550 borda: BTreeMap::new(),
1551 winner: 'A',
1552 rankings: 3,
1553 unanimous_initial: true,
1554 deliberated: false,
1555 changed_votes: 0,
1556 unanimous_final: true,
1557 tie_break: None,
1558 judges: 3,
1559 present: 3,
1560 quorum: 2,
1561 met_quorum: true,
1562 uncontested: None,
1563 });
1564 s
1565 }
1566
1567 #[test]
1568 fn run_report_names_the_winner_and_its_author() {
1569 let _guard = plain();
1570 let text = run(&state());
1571 assert!(text.contains("<- winner"), "{text}");
1572 assert!(text.contains("opus"));
1573 assert!(text.contains("3 files, 2 commits"));
1574 assert!(text.contains("winner A"));
1575 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1576 }
1577
1578 #[test]
1579 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1580 let _guard = plain();
1586 let mut s = state();
1587 s.candidates[0].summary =
1588 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1589 .to_owned();
1590 let text = run(&s);
1591 assert!(
1592 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1593 "{text}"
1594 );
1595 }
1596
1597 #[test]
1598 fn a_verified_noop_run_does_not_read_as_a_failure() {
1599 let _guard = plain();
1603 let mut s = state();
1604 s.status = RunStatus::VerifiedNoop;
1605 s.tally = None;
1606 s.candidates = vec![Candidate {
1607 index: 0,
1608 label: 'A',
1609 agent: "opus".to_owned(),
1610 branch: "magi/x/A".to_owned(),
1611 worktree: PathBuf::from("/wt/A"),
1612 summary: String::new(),
1613 stat: String::new(),
1614 files: 0,
1615 commits: 0,
1616 empty: true,
1617 failed: None,
1618 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1619 duration_ms: 9_000,
1620 folded: false,
1621 }];
1622 let text = run(&s);
1623 assert!(
1624 text.contains("agent-verified no-op"),
1625 "the status and the candidate flag must both say so: {text}"
1626 );
1627 assert!(
1628 text.contains("already fixed by b32cfc4"),
1629 "the evidence itself must be readable, not just the verdict: {text}"
1630 );
1631 assert!(
1632 !text.to_lowercase().contains("failed"),
1633 "a verified no-op must never read as the failure it is not: {text}"
1634 );
1635 }
1636
1637 #[test]
1638 fn colour_is_emitted_only_when_enabled() {
1639 let _guard = plain();
1640 set_color(true);
1641 let coloured = run(&state());
1642 set_color(false);
1643 let plain = run(&state());
1644 assert!(coloured.contains('\x1b'));
1645 assert!(!plain.contains('\x1b'));
1646 assert!(coloured.len() > plain.len());
1647 }
1648
1649 #[test]
1650 fn list_line_is_single_line() {
1651 let _guard = plain();
1652 let l = line(&state());
1653 assert_eq!(l.lines().count(), 1);
1654 assert!(l.contains("add retries"));
1655 assert!(l.contains("win A (opus)"));
1656 }
1657
1658 #[test]
1659 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1660 let _guard = plain();
1661 let mut s = state();
1662 s.tally = Some(Tally {
1663 first_choice: BTreeMap::from([('A', 0)]),
1664 borda: BTreeMap::new(),
1665 winner: 'A',
1666 rankings: 0,
1667 unanimous_initial: false,
1668 deliberated: false,
1669 changed_votes: 0,
1670 unanimous_final: false,
1671 tie_break: None,
1672 judges: 0,
1673 present: 0,
1674 quorum: 0,
1675 met_quorum: true,
1676 uncontested: Some(
1677 "only candidate A produced a usable change; no panel was asked".to_owned(),
1678 ),
1679 });
1680 let text = run(&s);
1681 assert!(
1682 !text.contains("0/3"),
1683 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1684 );
1685 assert!(!text.contains("no usable ranking"), "{text}");
1686 assert!(!text.contains("still split"), "{text}");
1687 assert!(!text.contains("BELOW QUORUM"), "{text}");
1688 assert!(
1689 text.contains("not needed"),
1690 "the report must say judging was skipped, not silent: {text}"
1691 );
1692 assert!(text.contains("winner A"));
1693 }
1694
1695 #[test]
1696 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1697 let _guard = plain();
1698 let mut s = state();
1699 s.tally = Some(Tally {
1700 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1701 borda: BTreeMap::new(),
1702 winner: 'A',
1703 rankings: 1,
1704 unanimous_initial: false,
1705 deliberated: false,
1706 changed_votes: 0,
1707 unanimous_final: false,
1708 tie_break: None,
1709 judges: 3,
1710 present: 1,
1711 quorum: 2,
1712 met_quorum: false,
1713 uncontested: None,
1714 });
1715 let text = run(&s);
1716 assert!(text.contains("1/3"), "{text}");
1717 assert!(
1718 text.contains("BELOW QUORUM"),
1719 "a real collapse must still be flagged: {text}"
1720 );
1721 assert!(
1722 !text.contains("not needed"),
1723 "a collapsed panel must not be described as one that was never asked: {text}"
1724 );
1725 }
1726
1727 #[test]
1728 fn a_mode_none_merge_does_not_read_as_landed() {
1729 let _guard = plain();
1730 let mut s = state();
1731 s.merge = Some(MergeOutcome {
1732 mode: crate::config::MergeMode::None,
1733 ok: true,
1734 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1735 empty: false,
1736 });
1737 let text = run(&s);
1738 assert!(
1739 !text.contains(" ok"),
1740 "mode none must not be shown as a landed merge: {text}"
1741 );
1742 assert!(text.contains("not landed"), "{text}");
1743 assert!(
1744 text.contains("branch magi/x/A"),
1745 "the report must say what's left behind: {text}"
1746 );
1747 assert!(
1748 text.contains("rebase"),
1749 "the report must point at the hand-landing steps: {text}"
1750 );
1751 assert!(
1752 !text.contains("placeholder subject"),
1753 "the default merge style is `merge`, which never inherits a \
1754 placeholder subject, so the squash caveat must not appear: {text}"
1755 );
1756 }
1757
1758 #[test]
1759 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1760 let _guard = plain();
1761 let mut s = state();
1762 s.config.merge.style = MergeStyle::Squash;
1763 s.merge = Some(MergeOutcome {
1764 mode: crate::config::MergeMode::None,
1765 ok: true,
1766 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1767 retries\""
1768 .to_owned(),
1769 empty: false,
1770 });
1771 let text = run(&s);
1772 assert!(
1773 text.contains("placeholder subject"),
1774 "a squash-style manual merge must warn about the missing message: {text}"
1775 );
1776 assert!(text.contains("--squash"), "{text}");
1777 }
1778
1779 #[test]
1780 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1781 let _guard = plain();
1782 let mut s = state();
1783 s.status = RunStatus::Ready;
1784 s.merge = Some(MergeOutcome {
1785 mode: crate::config::MergeMode::None,
1786 ok: true,
1787 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1788 empty: false,
1789 });
1790
1791 let list = line(&s);
1792 assert!(
1793 !list.contains(" ready "),
1794 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1795 );
1796 assert!(list.contains("no-op by design"), "{list}");
1797
1798 let full = run(&s);
1799 assert!(
1800 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1801 "the header line of `magi show` must not say plain ready either: {full}"
1802 );
1803 assert!(full.contains("no-op by design"), "{full}");
1804 }
1805
1806 #[test]
1807 fn an_ordinary_ready_run_still_reads_as_ready() {
1808 let _guard = plain();
1809 let mut s = state();
1810 s.status = RunStatus::Ready;
1811 s.merge = Some(MergeOutcome {
1815 mode: crate::config::MergeMode::Pr,
1816 ok: false,
1817 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1818 empty: false,
1819 });
1820
1821 let list = line(&s);
1822 assert!(list.contains("ready"), "{list}");
1823 assert!(!list.contains("no-op by design"), "{list}");
1824 }
1825
1826 #[test]
1827 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1828 let _guard = plain();
1829 let mut s = state();
1830 s.tally = Some(Tally {
1831 first_choice: BTreeMap::from([('A', 0)]),
1832 borda: BTreeMap::new(),
1833 winner: 'A',
1834 rankings: 0,
1835 unanimous_initial: false,
1836 deliberated: false,
1837 changed_votes: 0,
1838 unanimous_final: false,
1839 tie_break: None,
1840 judges: 0,
1841 present: 0,
1842 quorum: 0,
1843 met_quorum: true,
1844 uncontested: Some("only candidate A produced a usable change".to_owned()),
1845 });
1846 let l = line(&s);
1847 assert!(
1848 !l.contains("judges") && !l.contains("quorum"),
1849 "an uncontested run must not carry the same badge a short panel gets: {l}"
1850 );
1851 }
1852
1853 #[test]
1854 fn long_instructions_are_elided() {
1855 let _guard = plain();
1856 let mut s = state();
1857 s.instruction = "x".repeat(200);
1858 assert!(line(&s).contains('…'));
1859 }
1860
1861 #[test]
1862 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1863 let _guard = plain();
1864 let mut lost = state();
1865 lost.reviews = vec![ReviewRound {
1866 round: 1,
1867 head: "abc1234".to_owned(),
1868 verified_head: None,
1869 verified_at: None,
1870 reviews: Vec::new(),
1871 e2e: Vec::new(),
1872 verify_retried: false,
1873 e2e_deferred: false,
1874 e2e_defer_reason: None,
1875 fix: Some(FixRecord {
1876 agent: "opus".to_owned(),
1877 addressed: Vec::new(),
1878 rejected: Vec::new(),
1879 notes: String::new(),
1880 committed: true,
1881 failed: Some("timed out".to_owned()),
1882 duration_ms: 0,
1883 continuation: None,
1884 }),
1885 blocking: 3,
1886 answered: 0,
1887 expected: 0,
1888 clean: false,
1889 progressed: false,
1890 vote_split: false,
1891 reconsideration: Vec::new(),
1892 verdict: None,
1893 }];
1894 let text = run(&lost);
1895 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1896 assert!(
1897 !text.contains("0 addressed"),
1898 "a lost report must never read as `0 addressed`: {text}"
1899 );
1900
1901 let mut rejected_all = state();
1902 rejected_all.reviews = vec![ReviewRound {
1903 round: 1,
1904 head: "abc1234".to_owned(),
1905 verified_head: None,
1906 verified_at: None,
1907 reviews: Vec::new(),
1908 e2e: Vec::new(),
1909 verify_retried: false,
1910 e2e_deferred: false,
1911 e2e_defer_reason: None,
1912 fix: Some(FixRecord {
1913 agent: "opus".to_owned(),
1914 addressed: Vec::new(),
1915 rejected: Vec::new(),
1916 notes: String::new(),
1917 committed: true,
1918 failed: None,
1919 duration_ms: 0,
1920 continuation: None,
1921 }),
1922 blocking: 3,
1923 answered: 0,
1924 expected: 0,
1925 clean: false,
1926 progressed: false,
1927 vote_split: false,
1928 reconsideration: Vec::new(),
1929 verdict: None,
1930 }];
1931 let text2 = run(&rejected_all);
1932 assert!(
1933 text2.contains("0 addressed / 0 rejected"),
1934 "a round the fixer actually reported on keeps the count: {text2}"
1935 );
1936 }
1937
1938 #[test]
1939 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1940 use crate::run::ReviewRevoteRecord;
1941 use crate::verdict::ReviewVote;
1942
1943 let _guard = plain();
1944 let mut s = state();
1945 s.reviews = vec![ReviewRound {
1946 round: 1,
1947 head: "abc1234".to_owned(),
1948 verified_head: None,
1949 verified_at: None,
1950 reviews: vec![
1951 ReviewRecord {
1952 attempts: 0,
1953 reviewer: 1,
1954 agent: "alpha".to_owned(),
1955 summary: String::new(),
1956 findings: Vec::new(),
1957 vote: Some(ReviewVote::Approve),
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: Some(ReviewVote::Reject),
1968 failed: None,
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: None,
1977 blocking: 0,
1978 answered: 2,
1979 expected: 2,
1980 clean: false,
1981 progressed: false,
1982 vote_split: true,
1983 reconsideration: vec![ReviewRevoteRecord {
1984 reviewer: 2,
1985 agent: "beta".to_owned(),
1986 vote: Some(ReviewVote::ApproveWithFindings),
1987 reason: "the other seat's read holds up".to_owned(),
1988 failed: None,
1989 }],
1990 verdict: Some(ReviewVote::ApproveWithFindings),
1991 }];
1992 let text = run(&s);
1993 assert!(text.contains("review-1 vote"), "{text}");
1994 assert!(text.contains("review-2 vote"), "{text}");
1995 assert!(text.contains("panel split"), "{text}");
1996 assert!(text.contains("reconsideration"), "{text}");
1997 assert!(text.contains("the other seat's read holds up"), "{text}");
1998 }
1999
2000 #[test]
2001 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
2002 let _guard = plain();
2008 let mut s = state();
2009 s.reviews = vec![ReviewRound {
2010 round: 1,
2011 head: "abc1234".to_owned(),
2012 verified_head: None,
2013 verified_at: None,
2014 reviews: vec![
2015 ReviewRecord {
2016 attempts: 0,
2017 reviewer: 1,
2018 agent: "alpha".to_owned(),
2019 summary: String::new(),
2020 findings: Vec::new(),
2021 vote: None,
2022 failed: None,
2023 duration_ms: 0,
2024 },
2025 ReviewRecord {
2026 attempts: 0,
2027 reviewer: 2,
2028 agent: "beta".to_owned(),
2029 summary: String::new(),
2030 findings: Vec::new(),
2031 vote: None,
2032 failed: Some("agent timed out".to_owned()),
2033 duration_ms: 0,
2034 },
2035 ],
2036 e2e: Vec::new(),
2037 verify_retried: false,
2038 e2e_deferred: false,
2039 e2e_defer_reason: None,
2040 fix: Some(FixRecord {
2041 agent: "opus".to_owned(),
2042 addressed: Vec::new(),
2043 rejected: Vec::new(),
2044 notes: String::new(),
2045 committed: true,
2046 failed: Some("timed out".to_owned()),
2047 duration_ms: 0,
2048 continuation: None,
2049 }),
2050 blocking: 0,
2051 answered: 1,
2052 expected: 2,
2053 clean: false,
2054 progressed: true,
2055 vote_split: false,
2056 reconsideration: Vec::new(),
2057 verdict: None,
2058 }];
2059 let text = run(&s);
2060 assert!(text.contains("incomplete"), "{text}");
2061 assert!(text.contains("1/2 reviewers answered"), "{text}");
2062 assert!(text.contains("review-2: agent timed out"), "{text}");
2063 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2064 assert!(
2065 !text.contains("clean"),
2066 "a round missing half its panel must never render as clean: {text}"
2067 );
2068 }
2069
2070 #[test]
2071 fn a_build_failure_is_not_reported_as_a_test_failure() {
2072 let _guard = plain();
2073 let mut s = state();
2074 s.reviews = vec![ReviewRound {
2075 round: 1,
2076 head: "abc1234".to_owned(),
2077 verified_head: None,
2078 verified_at: None,
2079 reviews: Vec::new(),
2080 e2e: vec![CommandOutcome {
2081 command: "cargo test".to_owned(),
2082 code: Some(1),
2083 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2084 duration_ms: 100,
2085 resource_blocked: false,
2086 }],
2087 verify_retried: true,
2088 e2e_deferred: false,
2089 e2e_defer_reason: None,
2090 fix: None,
2091 blocking: 0,
2092 answered: 0,
2093 expected: 0,
2094 clean: false,
2095 progressed: false,
2096 vote_split: false,
2097 reconsideration: Vec::new(),
2098 verdict: None,
2099 }];
2100 let text = run(&s);
2101 assert!(text.contains("could not run"), "{text}");
2102 assert!(text.contains("retried once"), "{text}");
2103 assert!(!text.contains("e2e RED"), "{text}");
2104 }
2105
2106 #[test]
2107 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2108 let _guard = plain();
2109 let mut s = state();
2110 s.reviews = vec![ReviewRound {
2111 round: 1,
2112 head: "abc1234".to_owned(),
2113 verified_head: None,
2114 verified_at: None,
2115 reviews: Vec::new(),
2116 e2e: vec![CommandOutcome {
2117 command: "(waiting for the shared build cache)".to_owned(),
2118 code: None,
2119 output_tail: "held by run x node e2e seat e2e".to_owned(),
2120 duration_ms: 100,
2121 resource_blocked: true,
2122 }],
2123 verify_retried: false,
2124 e2e_deferred: false,
2125 e2e_defer_reason: None,
2126 fix: None,
2127 blocking: 0,
2128 answered: 0,
2129 expected: 0,
2130 clean: false,
2131 progressed: false,
2132 vote_split: false,
2133 reconsideration: Vec::new(),
2134 verdict: None,
2135 }];
2136 let text = run(&s);
2137 assert!(text.contains("shared build cache unavailable"), "{text}");
2138 assert!(!text.contains("e2e RED"), "{text}");
2139 assert!(!text.contains("build/link failure"), "{text}");
2140 }
2141
2142 #[test]
2143 fn a_round_with_more_than_one_e2e_command_names_each_one() {
2144 let _guard = plain();
2149 let mut s = state();
2150 s.reviews = vec![ReviewRound {
2151 round: 1,
2152 head: "abc1234".to_owned(),
2153 verified_head: Some("abc1234".to_owned()),
2154 verified_at: Some(jiff::Timestamp::now()),
2155 reviews: Vec::new(),
2156 e2e: vec![
2157 CommandOutcome {
2158 command: "cargo test --locked --all-targets".to_owned(),
2159 code: Some(0),
2160 output_tail: String::new(),
2161 duration_ms: 0,
2162 resource_blocked: false,
2163 },
2164 CommandOutcome {
2165 command: "cargo make check".to_owned(),
2166 code: Some(1),
2167 output_tail: "clippy: unused import".to_owned(),
2168 duration_ms: 0,
2169 resource_blocked: false,
2170 },
2171 ],
2172 verify_retried: false,
2173 e2e_deferred: false,
2174 e2e_defer_reason: None,
2175 fix: None,
2176 blocking: 0,
2177 answered: 0,
2178 expected: 0,
2179 clean: false,
2180 progressed: false,
2181 vote_split: false,
2182 reconsideration: Vec::new(),
2183 verdict: None,
2184 }];
2185 let text = run(&s);
2186 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2187 assert!(text.contains("cargo make check"), "{text}");
2188 assert!(text.contains("clippy: unused import"), "{text}");
2189 }
2190
2191 #[test]
2192 fn a_declined_finding_shows_its_reason() {
2193 use crate::verdict::{Finding, Rejection, Severity};
2194
2195 let _guard = plain();
2196 let mut s = state();
2197 s.status = RunStatus::Ready;
2198 s.reviews = vec![ReviewRound {
2199 round: 1,
2200 head: "deadbee".to_owned(),
2201 verified_head: None,
2202 verified_at: None,
2203 reviews: vec![ReviewRecord {
2204 attempts: 0,
2205 reviewer: 1,
2206 agent: "alpha".to_owned(),
2207 summary: String::new(),
2208 findings: vec![Finding {
2209 id: "R1-1-1".to_owned(),
2210 severity: Severity::Major,
2211 file: None,
2212 line: None,
2213 title: "still open".to_owned(),
2214 detail: String::new(),
2215 }],
2216 vote: None,
2217 failed: None,
2218 duration_ms: 0,
2219 }],
2220 e2e: vec![CommandOutcome {
2221 command: "cargo test".to_owned(),
2222 code: Some(0),
2223 output_tail: String::new(),
2224 duration_ms: 0,
2225 resource_blocked: false,
2226 }],
2227 verify_retried: false,
2228 e2e_deferred: false,
2229 e2e_defer_reason: None,
2230 fix: Some(FixRecord {
2231 agent: "alpha".to_owned(),
2232 addressed: Vec::new(),
2233 rejected: vec![Rejection {
2234 id: "R1-1-2".to_owned(),
2235 why: "cannot be triggered from any caller".to_owned(),
2236 }],
2237 notes: String::new(),
2238 committed: true,
2239 failed: None,
2240 duration_ms: 0,
2241 continuation: None,
2242 }),
2243 blocking: 1,
2244 answered: 1,
2245 expected: 1,
2246 clean: false,
2247 progressed: true,
2248 vote_split: false,
2249 reconsideration: Vec::new(),
2250 verdict: None,
2251 }];
2252
2253 let text = run(&s);
2254 assert!(text.contains("R1-1-2"), "{text}");
2255 assert!(text.contains("cannot be triggered"), "{text}");
2256 assert!(text.contains("still open"), "{text}");
2257 assert!(
2258 text.contains("handed off"),
2259 "a mergeable run with an open round must say so: {text}"
2260 );
2261 }
2262
2263 #[test]
2264 fn a_failing_gate_command_shows_its_output() {
2265 let _guard = plain();
2266 let mut s = state();
2267 s.status = RunStatus::Blocked;
2268 s.gate = vec![CommandOutcome {
2269 command: "cargo make check".to_owned(),
2270 code: Some(101),
2271 output_tail: "error[E0308]: mismatched types".to_owned(),
2272 duration_ms: 0,
2273 resource_blocked: false,
2274 }];
2275 s.gate_ran = true;
2276
2277 let text = run(&s);
2278 assert!(text.contains("mismatched types"), "{text}");
2279 }
2280
2281 #[test]
2282 fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
2283 let _guard = plain();
2284 let mut s = state();
2285 s.status = RunStatus::Ready;
2286 s.gate_ran = true;
2287 assert!(s.gate.is_empty());
2288
2289 let text = run(&s);
2290 assert!(
2291 text.contains("gate") && text.contains("no gate commands configured"),
2292 "a run gated on nothing must say so, not read as if the gate never ran: {text}"
2293 );
2294 }
2295
2296 #[test]
2297 fn a_gate_that_has_not_run_yet_shows_nothing() {
2298 let _guard = plain();
2299 let s = state();
2300 assert!(!s.gate_ran);
2301 assert!(s.gate.is_empty());
2302
2303 let text = run(&s);
2304 assert!(
2305 !text.contains("no gate commands configured"),
2306 "an unattempted gate must not be shown as a pass: {text}"
2307 );
2308 }
2309
2310 #[test]
2311 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2312 let _guard = plain();
2313 let mut s = state();
2314 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2315 let text = active_seats(&s, Liveness::Live);
2316 assert!(text.contains("running now"));
2317 assert!(text.contains("judge-2"));
2318 assert!(text.contains("judge"));
2319 assert!(!text.contains("no live daemon"), "{text}");
2320 assert!(!text.contains("could not be confirmed"), "{text}");
2321 }
2322
2323 #[test]
2324 fn active_seats_flags_a_leftover_from_a_dead_process() {
2325 let _guard = plain();
2326 let mut s = state();
2327 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2328 let text = active_seats(&s, Liveness::Dead);
2329 assert!(
2330 text.contains("no live daemon"),
2331 "a stale entry must not read as running: {text}"
2332 );
2333 }
2334
2335 #[test]
2336 fn superseded_notice_names_the_run_that_replaced_it() {
2337 let _guard = plain();
2338 let text = superseded_notice(Some("bbbb"));
2339 assert!(text.contains("SUPERSEDED"), "{text}");
2340 assert!(text.contains("bbbb"), "{text}");
2341 }
2342
2343 #[test]
2344 fn superseded_notice_is_silent_for_the_latest_attempt() {
2345 let _guard = plain();
2346 assert_eq!(superseded_notice(None), "");
2347 }
2348
2349 #[test]
2350 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2351 let _guard = plain();
2352 let mut s = state();
2353 s.status = RunStatus::Reviewing;
2354 let text = line_with_liveness(&s, Liveness::Dead);
2355 assert!(text.contains("STALE"), "{text}");
2356 assert!(text.contains("resume required"), "{text}");
2357 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2358 }
2359
2360 #[test]
2365 fn active_seats_reports_uncertainty_without_claiming_death() {
2366 let _guard = plain();
2367 let mut s = state();
2368 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2369 let text = active_seats(&s, Liveness::Unknown);
2370 assert!(
2371 text.contains("could not be confirmed"),
2372 "an unproven state must read as uncertain, not dead: {text}"
2373 );
2374 assert!(!text.contains("no live daemon"), "{text}");
2375 }
2376
2377 #[test]
2378 fn active_seats_is_empty_when_nothing_is_running() {
2379 let _guard = plain();
2380 assert_eq!(active_seats(&state(), Liveness::Live), "");
2381 }
2382
2383 #[test]
2389 fn active_seats_shows_a_running_verify_task_and_its_command() {
2390 let _guard = plain();
2391 let mut s = state();
2392 s.task_command(
2393 "e2e",
2394 "verify",
2395 0,
2396 "cargo test",
2397 2,
2398 3,
2399 std::time::Duration::from_secs(600),
2400 );
2401 let text = active_seats(&s, Liveness::Live);
2402 assert!(text.contains("e2e"), "{text}");
2403 assert!(text.contains("(2/3)"), "{text}");
2404 assert!(text.contains("cargo test"), "{text}");
2405 }
2406
2407 #[test]
2408 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2409 let _guard = plain();
2410 assert!(!run(&state()).contains("background jobs"));
2413 }
2414
2415 #[test]
2416 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2417 let _guard = plain();
2418 let mut s = state();
2419 s.config.agents.push(crate::config::AgentSpec {
2420 id: "codex-one".to_owned(),
2421 kind: crate::config::AgentKind::Codex,
2422 model: None,
2423 command: vec!["codex".to_owned()],
2424 extra_args: Vec::new(),
2425 env: BTreeMap::new(),
2426 prompt_delivery: None,
2427 });
2428 let text = run(&s);
2429 assert!(
2430 text.contains("background jobs"),
2431 "a run that could report this must not read the same as one that never could: \
2432 {text}"
2433 );
2434 assert!(text.contains("no completed command evidence yet"));
2435 }
2436
2437 #[test]
2438 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2439 let _guard = plain();
2440 let mut s = state();
2441 s.jobs = vec![
2442 crate::run::JobRecord {
2443 node: "implement".to_owned(),
2444 round: None,
2445 seat: "impl-A".to_owned(),
2446 id: "item49".to_owned(),
2447 description: "cargo test --test graph_cached_gate".to_owned(),
2448 checked_at: jiff::Timestamp::now(),
2449 status: crate::run::JobStatus::Completed,
2450 exit_code: Some(0),
2451 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2452 source: "codex".to_owned(),
2453 },
2454 crate::run::JobRecord {
2455 node: "fix".to_owned(),
2456 round: None,
2457 seat: "impl-A".to_owned(),
2458 id: "item52".to_owned(),
2459 description: "cargo test --test graph_split".to_owned(),
2460 checked_at: jiff::Timestamp::now(),
2461 status: crate::run::JobStatus::Failed,
2462 exit_code: Some(101),
2463 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2464 source: "codex".to_owned(),
2465 },
2466 crate::run::JobRecord {
2467 node: "fix".to_owned(),
2468 round: None,
2469 seat: "impl-A".to_owned(),
2470 id: "item60".to_owned(),
2471 description: "cargo build".to_owned(),
2472 checked_at: jiff::Timestamp::now(),
2473 status: crate::run::JobStatus::Unknown,
2474 exit_code: None,
2475 result_summary: String::new(),
2476 source: "codex".to_owned(),
2477 },
2478 ];
2479 let text = run(&s);
2480 assert!(text.contains("background jobs"));
2481 assert!(text.contains("item49"));
2482 assert!(text.contains("item52"));
2483 assert!(text.contains("item60"));
2484 assert!(text.contains("completed"));
2488 assert!(text.contains("failed"));
2489 assert!(text.contains("unknown"));
2490 assert!(text.contains("adapter coverage"));
2492 }
2493
2494 #[test]
2495 fn a_jobs_own_round_is_shown_when_known() {
2496 let _guard = plain();
2497 let mut s = state();
2498 s.jobs = vec![crate::run::JobRecord {
2499 node: "review".to_owned(),
2500 round: Some(2),
2501 seat: "review-1".to_owned(),
2502 id: "item9".to_owned(),
2503 description: "cargo test --test graph_cached_gate".to_owned(),
2504 checked_at: jiff::Timestamp::now(),
2505 status: crate::run::JobStatus::Completed,
2506 exit_code: Some(0),
2507 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2508 source: "codex".to_owned(),
2509 }];
2510 let text = run(&s);
2511 assert!(
2512 text.contains("round 2"),
2513 "the round this seat's own command ran in must be visible, distinct from magi's \
2514 own recorded verify: {text}"
2515 );
2516 }
2517
2518 #[test]
2519 fn stats_table_renders_without_runs() {
2520 let _guard = plain();
2521 let text = stats(&Stats::default());
2522 assert!(text.contains("0 total"));
2523 assert!(!text.contains("implementation"));
2524 assert!(!text.contains("node durations"));
2525 }
2526
2527 #[test]
2528 fn repo_summary_lists_run_count_and_completion_rate() {
2529 let _guard = plain();
2530 let a = crate::stats::RepoStats {
2531 repo: PathBuf::from("/repos/a"),
2532 name: "a".to_owned(),
2533 stats: Stats {
2534 totals: crate::stats::Totals {
2535 runs: 4,
2536 merged: 2,
2537 ready: 1,
2538 ..Default::default()
2539 },
2540 ..Default::default()
2541 },
2542 };
2543 let text = repo_summary(&[a]);
2544 assert!(text.contains("repos"), "{text}");
2545 assert!(text.contains("a 4 runs 75% completion"), "{text}");
2546 }
2547
2548 #[test]
2549 fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2550 let _guard = plain();
2551 let a = crate::stats::RepoStats {
2552 repo: PathBuf::from("/roots/one/magi"),
2553 name: "magi".to_owned(),
2554 stats: Stats::default(),
2555 };
2556 let b = crate::stats::RepoStats {
2557 repo: PathBuf::from("/roots/two/magi"),
2558 name: "magi".to_owned(),
2559 stats: Stats::default(),
2560 };
2561 let text = repo_summary(&[a, b]);
2562 assert!(text.contains("magi (one)"), "{text}");
2563 assert!(text.contains("magi (two)"), "{text}");
2564 }
2565
2566 #[test]
2567 fn stats_table_renders_advisor_reflection() {
2568 let _guard = plain();
2569 let st = Stats {
2570 advisors: vec![crate::stats::AdvisorStats {
2571 agent: "alpha".to_owned(),
2572 seated: 3,
2573 proposed: 2,
2574 absent: 1,
2575 faint: 1,
2576 strong: 1,
2577 }],
2578 ..Default::default()
2579 };
2580 let text = stats(&st);
2581 assert!(text.contains("design deliberation"));
2582 assert!(
2583 text.contains("approximate"),
2584 "must note the heuristic: {text}"
2585 );
2586 assert!(text.contains("alpha"));
2587 assert!(text.contains("50%"));
2588 }
2589
2590 #[test]
2591 fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2592 let _guard = plain();
2597 let st = Stats {
2598 release_bumps: crate::stats::ReleaseBumpStats {
2599 merged: 3,
2600 ..Default::default()
2601 },
2602 ..Default::default()
2603 };
2604 let text = stats(&st);
2605 assert!(text.contains("release bumps"), "{text}");
2606 assert!(
2607 text.contains("0 of 3 merged run(s) recorded a bump"),
2608 "{text}"
2609 );
2610 }
2611
2612 #[test]
2613 fn stats_table_renders_release_bump_coverage_and_attention() {
2614 let _guard = plain();
2615 let st = Stats {
2616 release_bumps: crate::stats::ReleaseBumpStats {
2617 merged: 4,
2618 recorded: 2,
2619 pr_opened: 2,
2620 automerge_enabled: 1,
2621 merged_directly: 0,
2622 needs_attention: 1,
2623 },
2624 ..Default::default()
2625 };
2626 let text = stats(&st);
2627 assert!(text.contains("release bumps"), "{text}");
2628 assert!(
2629 text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2630 "{text}"
2631 );
2632 assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2633 }
2634
2635 #[test]
2636 fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2637 let _guard = plain();
2638 let st = Stats {
2639 nodes: vec![
2640 crate::stats::NodeDuration {
2641 node: "implement".to_owned(),
2642 runs: 2,
2643 total_secs: 200,
2644 max_secs: 150,
2645 single: 1,
2646 },
2647 crate::stats::NodeDuration {
2648 node: "gate".to_owned(),
2649 runs: 0,
2650 total_secs: 0,
2651 max_secs: 0,
2652 single: 3,
2653 },
2654 ],
2655 ..Default::default()
2656 };
2657 let text = stats(&st);
2658 assert!(text.contains("node durations"));
2659 assert!(text.contains("implement"));
2660 assert!(text.contains("2 runs"));
2661 assert!(text.contains('█'), "the leader must draw a bar: {text}");
2662 assert!(text.contains("1 unmeasured"));
2663 assert!(text.contains("gate"));
2664 assert!(text.contains("3 unmeasured"));
2665 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2666 }
2667
2668 #[test]
2669 fn implementation_rates_use_independent_low_n_boundaries() {
2670 let _guard = plain();
2671 for (competitive_n, solo_n) in [(9, 10), (10, 9)] {
2672 let text = stats(&Stats {
2673 agents: vec![crate::stats::AgentStats {
2674 agent: "alpha".to_owned(),
2675 competitive_entered: competitive_n,
2676 competitive_wins: 1,
2677 solo_entered: solo_n,
2678 solo_adopted: 2,
2679 ..Default::default()
2680 }],
2681 ..Default::default()
2682 });
2683 let (competitive, solo) = text
2684 .split_once("implementation (solo runs, completion/adoption rate)")
2685 .unwrap();
2686 for (section, n, numerator) in [(competitive, competitive_n, 1), (solo, solo_n, 2)] {
2687 let row = section.lines().find(|line| line.contains("alpha")).unwrap();
2688 let cells: Vec<_> = row.split_whitespace().collect();
2689 assert_eq!(cells[1], numerator.to_string());
2690 assert_eq!(cells[2], n.to_string());
2691 assert_eq!(row.contains("low n"), n < 10);
2692 }
2693 }
2694 }
2695
2696 #[test]
2697 fn stats_table_renders_competitive_and_solo_tables_with_low_n() {
2698 let _guard = plain();
2699 let st = Stats {
2700 agents: vec![
2701 crate::stats::AgentStats {
2702 agent: "alpha".to_owned(),
2703 entered: 15,
2704 wins: 10,
2705 empty: 0,
2706 competitive_entered: 12,
2707 competitive_wins: 9,
2708 solo_entered: 3,
2709 solo_adopted: 1,
2710 },
2711 crate::stats::AgentStats {
2712 agent: "beta".to_owned(),
2713 entered: 5,
2714 wins: 2,
2715 empty: 1,
2716 competitive_entered: 4,
2717 competitive_wins: 2,
2718 solo_entered: 1,
2719 solo_adopted: 0,
2720 },
2721 ],
2722 ..Default::default()
2723 };
2724 let text = stats(&st);
2725 assert!(text.contains("implementation (competitive runs, 2+ candidates)"));
2726 assert!(text.contains("alpha"));
2727 assert!(text.contains("75%"));
2728 assert!(text.contains("beta"));
2729 assert!(text.contains("50%"));
2730 assert!(text.contains("implementation (solo runs, completion/adoption rate)"));
2731 assert!(text.contains("33%"));
2732 assert!(text.contains("low n"));
2733 }
2734}