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