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