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