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::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 match state.status {
73 RunStatus::Merged => bold(&green(text)),
74 RunStatus::Ready => green(text),
75 RunStatus::Stalled => bold(&yellow(text)),
76 RunStatus::Blocked => yellow(text),
77 RunStatus::Failed => red(text),
78 RunStatus::VerifiedNoop => cyan(text),
82 _ => cyan(text),
83 }
84}
85
86fn vote_tag(vote: ReviewVote) -> String {
90 let text = vote.label();
91 match vote {
92 ReviewVote::Approve => green(text),
93 ReviewVote::ApproveWithFindings => yellow(text),
94 ReviewVote::Reject => red(text),
95 }
96}
97
98pub fn line(state: &RunState) -> String {
100 line_with_liveness(state, Liveness::Unknown)
101}
102
103pub fn line_with_liveness(state: &RunState, live: Liveness) -> String {
106 let winner = state
107 .tally
108 .as_ref()
109 .map_or("-".to_owned(), |t| t.winner.to_string());
110 let agent = state.winner().map_or("-", |c| c.agent.as_str());
111 let quorum = match state.tally.as_ref() {
114 Some(t) if !t.met_quorum => format!(
115 " {}",
116 bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
117 ),
118 Some(t) if t.present > 0 && t.present < t.judges => format!(
119 " {}",
120 yellow(&format!("judges {}/{}", t.present, t.judges))
121 ),
122 _ => String::new(),
123 };
124 let stale = if !state.status.done() && live == Liveness::Dead {
125 format!(
126 " {}",
127 bold(&yellow("STALE — driver exited; resume required"))
128 )
129 } else {
130 String::new()
131 };
132 format!(
133 "{} {:<20} {:>2}c {:>2}j win {} ({}){quorum}{stale} {}",
134 dim(&state.id),
135 status_word(state),
136 state.candidates.len(),
137 state.judgements.len(),
138 winner,
139 agent,
140 first_line(&state.instruction)
141 )
142}
143
144pub fn liveness_notice(state: &RunState, live: Liveness) -> String {
148 if !state.status.done() && live == Liveness::Dead {
149 format!(
150 "{}\n\n",
151 yellow("STALE — the process driving this run exited; resume it to continue.")
152 )
153 } else {
154 String::new()
155 }
156}
157
158fn first_line(text: &str) -> String {
159 let line = text.lines().next().unwrap_or_default();
160 if line.chars().count() > 68 {
161 format!("{}…", line.chars().take(67).collect::<String>())
162 } else {
163 line.to_owned()
164 }
165}
166
167fn short(commit: &str) -> String {
168 commit.chars().take(7).collect()
169}
170
171fn continuation_note(c: &crate::run::ContinuationRecord) -> String {
176 match c.outcome {
177 ContinuationOutcome::NotNeeded => String::new(),
178 ContinuationOutcome::Resumed => format!(" [resumed x{}]", c.attempts),
179 ContinuationOutcome::Exhausted => format!(" [continuation exhausted x{}]", c.attempts),
180 ContinuationOutcome::QuotaLost => " [continuation: quota]".to_owned(),
181 ContinuationOutcome::NoSession => " [no session to resume]".to_owned(),
182 }
183}
184
185fn jobs_section(state: &RunState) -> String {
206 let mut s = String::new();
207 if state.jobs.is_empty() {
208 if state
213 .config
214 .agents
215 .iter()
216 .any(|a| a.kind == crate::config::AgentKind::Codex)
217 {
218 let _ = writeln!(
219 s,
220 "\n{}",
221 dim(
222 "background jobs: no completed command evidence yet for this run (see \
223 active seats above for what is still mid-turn)"
224 )
225 );
226 }
227 return s;
228 }
229 let _ = writeln!(
230 s,
231 "\n{}",
232 bold("background jobs (from each seat's own CLI)")
233 );
234 let mut by_seat: std::collections::BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> =
235 std::collections::BTreeMap::new();
236 for j in &state.jobs {
237 by_seat
238 .entry((j.node.as_str(), j.seat.as_str()))
239 .or_default()
240 .push(j);
241 }
242 for ((node, seat), records) in by_seat {
243 let _ = writeln!(s, " {node}/{seat}");
244 for j in records {
245 let status = match j.status {
246 JobStatus::Completed => green("completed"),
247 JobStatus::Failed => red("failed"),
248 JobStatus::Unknown => yellow("unknown"),
249 };
250 let _ = writeln!(
251 s,
252 " {} {}{}{} checked {}",
253 dim(&j.id),
254 status,
255 j.exit_code
256 .map_or(String::new(), |c| format!(" (exit {c})")),
257 j.round.map_or(String::new(), |r| format!(" round {r}")),
258 j.checked_at
259 .to_zoned(jiff::tz::TimeZone::system())
260 .strftime("%Y-%m-%d %H:%M:%S")
261 );
262 let desc = first_line(&j.description);
263 if !desc.trim().is_empty() {
264 let _ = writeln!(s, " $ {desc}");
265 }
266 let summary = first_line(&j.result_summary);
267 if !summary.trim().is_empty() {
268 let _ = writeln!(s, " {}", dim(&summary));
269 }
270 }
271 }
272 let _ = writeln!(
273 s,
274 " {}",
275 dim(
276 "(adapter coverage: codex only today; other backends, and a command a CLI never \
277 reported finishing, leave no entry here — that is unknown, never \"nothing ran\")"
278 )
279 );
280 s
281}
282
283pub fn run(state: &RunState) -> String {
285 let mut s = String::new();
286 let _ = writeln!(
287 s,
288 "{} {} {}",
289 bold("magi run"),
290 bold(&state.id),
291 status_word(state)
292 );
293 let _ = writeln!(
294 s,
295 " repo {} ({} @ {})",
296 state.repo.display(),
297 state.base_branch,
298 short(&state.base_commit)
299 );
300 let _ = writeln!(s, " created {}", state.created_local());
301 let _ = writeln!(s, " task {}", first_line(&state.instruction));
302 let _ = writeln!(s, " state {}", state.dir().display());
303
304 let _ = writeln!(s, "\n{}", bold("candidates"));
305 for c in &state.candidates {
306 let flag = match (&c.failed, c.empty, &c.verified_noop) {
307 (Some(e), _, _) => red(&format!("failed: {e}")),
308 (None, true, Some(_)) => cyan("agent-verified no-op (unconfirmed)"),
312 (None, true, None) => yellow("no change"),
313 _ => format!("{} files, {} commits", c.files, c.commits),
314 };
315 let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
316 bold(&green(" <- winner"))
317 } else {
318 String::new()
319 };
320 let _ = writeln!(
321 s,
322 " {} {:<12} {:<30} {:>5}s{}",
323 bold(&c.label.to_string()),
324 c.agent,
325 flag,
326 c.duration_ms / 1000,
327 crown
328 );
329 if let Some(evidence) = &c.verified_noop {
330 let _ = writeln!(s, " {}", dim(&first_line(evidence)));
331 } else if !c.summary.trim().is_empty() {
332 let _ = writeln!(s, " {}", dim(&first_line(&c.summary)));
341 }
342 }
343
344 if !state.judgements.is_empty() {
345 let _ = writeln!(s, "\n{}", bold("blind judging"));
346 for j in &state.judgements {
347 match &j.failed {
348 Some(e) => {
349 let _ = writeln!(
350 s,
351 " judge {} {}",
352 j.judge,
353 red(&format!("no ranking: {e}"))
354 );
355 }
356 None => {
357 let _ = writeln!(
358 s,
359 " judge {} {:<12} {} confidence {}",
360 j.judge,
361 j.agent,
362 bold(&j.ranking.iter().collect::<String>()),
363 j.confidence.map_or("-".to_owned(), |c| c.to_string())
364 );
365 }
366 }
367 }
368 }
369
370 if let Some(t) = &state.tally {
371 if t.deliberated {
372 let _ = writeln!(s, "\n{}", bold("deliberation"));
373 for round in &state.deliberation {
374 for turn in &round.turns {
375 let _ = writeln!(
376 s,
377 " r{} judge {} -> {}",
378 round.round,
379 turn.judge,
380 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
381 );
382 }
383 }
384 }
385
386 if !state.votes.is_empty() {
387 let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
388 for v in &state.votes {
389 let _ = writeln!(
390 s,
391 " judge {} {:<12} {}{}",
392 v.judge,
393 v.agent,
394 bold(&v.vote.unwrap_or('?').to_string()),
395 if v.changed {
396 yellow(" (changed after deliberation)")
397 } else {
398 String::new()
399 }
400 );
401 }
402 }
403
404 let _ = writeln!(s, "\n{}", bold("tally"));
405 match &t.uncontested {
411 Some(reason) => {
412 let _ = writeln!(
413 s,
414 " judging {}",
415 cyan(&format!("not needed — {reason}"))
416 );
417 }
418 None => {
419 let _ = writeln!(
420 s,
421 " judges {} present{}",
422 if t.met_quorum {
423 green(&format!("{}/{}", t.present, t.judges))
424 } else {
425 red(&format!("{}/{}", t.present, t.judges))
426 },
427 if t.quorum > 0 {
428 format!(" ({quorum} required)", quorum = t.quorum)
429 } else {
430 String::new()
431 }
432 );
433 if !t.met_quorum {
434 let _ = writeln!(
435 s,
436 " {}",
437 bold(&red("BELOW QUORUM — verdict is not trustworthy"))
438 );
439 }
440 let _ = writeln!(
441 s,
442 " first choice {}",
443 t.first_choice
444 .iter()
445 .map(|(k, v)| format!("{k}:{v}"))
446 .collect::<Vec<_>>()
447 .join(" ")
448 );
449 let _ = writeln!(
450 s,
451 " initial {}",
452 match (t.rankings, t.unanimous_initial) {
453 (0, _) => red("no usable ranking"),
454 (1, _) => yellow("one usable ranking - not a consensus"),
455 (_, true) => green("unanimous"),
456 (_, false) => yellow("split"),
457 }
458 );
459 let _ = writeln!(
460 s,
461 " after votes {} ({} judge(s) moved)",
462 if t.unanimous_final {
463 green("unanimous")
464 } else {
465 yellow("still split")
466 },
467 t.changed_votes
468 );
469 if let Some(tb) = &t.tie_break {
470 let _ = writeln!(s, " tie break {tb}");
471 }
472 }
473 }
474 if !state.quota.is_empty() {
475 let _ = writeln!(
476 s,
477 " rate limited {}",
478 state
479 .quota
480 .iter()
481 .map(|q| q.seat.as_str())
482 .collect::<Vec<_>>()
483 .join(", ")
484 );
485 }
486 let _ = writeln!(s, " winner {}", bold(&green(&t.winner.to_string())));
487 }
488
489 if !state.withheld.is_empty() {
490 let _ = writeln!(s, "\n{}", bold("withheld from commit"));
491 for w in &state.withheld {
492 let _ = writeln!(
493 s,
494 " {} {} ({} lockfile; the directory uses {})",
495 yellow("!"),
496 w.path,
497 w.manager,
498 w.kept_by
499 );
500 }
501 }
502
503 if !state.reviews.is_empty() {
504 let _ = writeln!(s, "\n{}", bold("review + verification"));
505 for r in &state.reviews {
506 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
507 let e2e = match r.e2e_status() {
515 E2eStatus::NotConfigured => dim("no e2e"),
516 E2eStatus::Deferred => yellow(&format!(
517 "e2e deferred{}",
518 r.e2e_defer_reason
519 .as_deref()
520 .map(|why| format!(" ({why})"))
521 .unwrap_or_default()
522 )),
523 E2eStatus::Passed => green("e2e green"),
524 E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
525 yellow("e2e could not run (build/link failure)")
526 }
527 E2eStatus::Failed => red("e2e RED"),
528 E2eStatus::ResourceBlocked => {
533 yellow("e2e could not run (shared build cache unavailable)")
534 }
535 };
536 let e2e = if r.verify_retried {
537 format!("{e2e}, retried once")
538 } else {
539 e2e
540 };
541 let status = if r.incomplete() {
545 yellow("incomplete")
546 } else if r.clean {
547 green("clean")
548 } else {
549 yellow("open")
550 };
551 let panel = if r.incomplete() {
555 let missing: Vec<String> = r
556 .reviews
557 .iter()
558 .filter_map(|x| {
559 x.failed
560 .as_ref()
561 .map(|why| format!("review-{}: {why}", x.reviewer))
562 })
563 .collect();
564 format!(
565 " {}/{} reviewers answered ({})",
566 r.answered,
567 r.expected,
568 missing.join(", ")
569 )
570 } else {
571 String::new()
572 };
573 let verdict = r.verdict.map_or(String::new(), |v| {
579 format!(
580 ", verdict {}{}",
581 vote_tag(v),
582 if r.vote_split { " (panel split)" } else { "" }
583 )
584 });
585 let _ = writeln!(
586 s,
587 " round {} {} @ {}{}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
588 r.round,
589 status,
590 short(&r.head),
591 r.verified_head.as_ref().map_or(String::new(), |head| {
592 format!(
593 " (verified @ {}{})",
594 short(head),
595 r.verified_at.map_or(String::new(), |t| format!(
596 " on {}",
597 t.to_zoned(jiff::tz::TimeZone::system())
598 .strftime("%Y-%m-%d %H:%M:%S")
599 ))
600 )
601 }),
602 r.blocking,
603 r.fix.as_ref().map_or(String::new(), |f| {
604 let tree = if r.progressed {
605 green("changed")
606 } else {
607 yellow("unchanged")
608 };
609 let cont = f
610 .continuation
611 .as_ref()
612 .map_or(String::new(), continuation_note);
613 match &f.failed {
614 Some(reason) => format!(
619 " fix: {}, tree {tree}{}{cont}",
620 yellow(&format!("adoption report lost ({reason})")),
621 if f.committed {
622 String::new()
623 } else {
624 red(" (NO COMMIT)")
625 }
626 ),
627 None => format!(
628 " fix: {} addressed / {} rejected, tree {tree}{}{cont}",
629 f.addressed.len(),
630 f.rejected.len(),
631 if f.committed {
632 String::new()
633 } else {
634 red(" (NO COMMIT)")
635 }
636 ),
637 }
638 })
639 );
640 for o in &r.e2e {
647 let label = if o.resource_blocked {
648 yellow("blocked")
649 } else if o.ok() {
650 green("pass")
651 } else {
652 red("FAIL")
653 };
654 let _ = writeln!(s, " {label} {}", o.command);
655 if !o.ok() {
656 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
657 }
658 }
659 for rec in &r.reviews {
660 if let Some(vote) = rec.vote {
661 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
662 }
663 for f in &rec.findings {
664 let adopted = r
665 .fix
666 .as_ref()
667 .is_some_and(|fix| fix.addressed.contains(&f.id));
668 let _ = writeln!(
669 s,
670 " {} [{:?}] {}{}",
671 dim(&f.id),
672 f.severity,
673 f.title,
674 if adopted {
675 green(" fixed")
676 } else {
677 String::new()
678 }
679 );
680 }
681 }
682 if let Some(fix) = &r.fix {
683 for rej in &fix.rejected {
684 let _ = writeln!(
685 s,
686 " {} {}: {}",
687 dim(&rej.id),
688 yellow("declined"),
689 rej.why
690 );
691 }
692 }
693 if !r.reconsideration.is_empty() {
697 let _ = writeln!(s, " {}", dim("reconsideration:"));
698 for rv in &r.reconsideration {
699 match rv.vote {
700 Some(v) => {
701 let _ = writeln!(
702 s,
703 " review-{} -> {} {}",
704 rv.reviewer,
705 vote_tag(v),
706 rv.reason
707 );
708 }
709 None => {
710 let _ = writeln!(
711 s,
712 " review-{} -> {}",
713 rv.reviewer,
714 red(&format!(
715 "no revote ({})",
716 rv.failed.as_deref().unwrap_or("unknown")
717 ))
718 );
719 }
720 }
721 }
722 }
723 }
724 if state.handed_off_with_open_findings() {
725 let _ = writeln!(
726 s,
727 "\n {}",
728 yellow(&format!(
729 "handed off with {} finding(s) still open — gate and e2e were green; \
730 see above for what a person should still look at",
731 state.open_findings().len()
732 ))
733 );
734 }
735 }
736
737 if !state.operator_fixes.is_empty() {
738 let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
739 for (i, req) in state.operator_fixes.iter().enumerate() {
740 let _ = writeln!(
741 s,
742 " [{}] {} finding(s) at {}{}",
743 i + 1,
744 req.findings.len(),
745 req.requested_at
746 .to_zoned(jiff::tz::TimeZone::system())
747 .strftime("%Y-%m-%d %H:%M:%S"),
748 if req.stale {
749 yellow(" stale head, --allow-stale used")
750 } else {
751 String::new()
752 }
753 );
754 let _ = writeln!(s, " reason: {}", req.reason);
755 for f in &req.findings {
756 let outcome = match &f.outcome {
757 OperatorFixOutcome::Pending => yellow("pending"),
758 OperatorFixOutcome::Addressed => green("addressed"),
759 OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
760 OperatorFixOutcome::Unreported => {
761 red("unreported — no adoption report came back")
762 }
763 };
764 let _ = writeln!(
765 s,
766 " {} [{:?}] {} {outcome}",
767 dim(&f.id),
768 f.severity,
769 f.title
770 );
771 }
772 match &req.follow_up_review_run {
773 Some(id) => {
774 let _ = writeln!(s, " re-verified by run {id}");
775 }
776 None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
777 let _ = writeln!(
778 s,
779 " {}",
780 red("committed, but the follow-up review could not be opened")
781 );
782 }
783 None => {
784 let _ = writeln!(s, " no change committed; nothing to re-verify");
785 }
786 }
787 }
788 }
789
790 if let Some(bs) = &state.base_sync {
791 let _ = writeln!(s, "\n{}", bold("base sync"));
792 let status = if let Some(c) = &bs.conflict {
793 red(&format!("conflict: {}", first_line(c)))
794 } else if bs.behind == 0 {
795 green("in sync")
796 } else {
797 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
798 };
799 let _ = writeln!(
800 s,
801 " {} @ {} {status}{}",
802 state.base_branch,
803 short(&bs.tip),
804 if bs.attempts > 0 {
805 format!(" ({} rebase attempt(s))", bs.attempts)
806 } else {
807 String::new()
808 }
809 );
810 }
811
812 if !state.pre_gate.is_empty() {
813 let _ = writeln!(s, "\n{}", bold("pre_gate"));
814 for o in &state.pre_gate {
815 let _ = writeln!(
816 s,
817 " {} {}",
818 if o.ok() {
819 green("pass")
820 } else {
821 yellow("warn")
822 },
823 o.command
824 );
825 if !o.ok() {
826 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
827 }
828 }
829 if let Some(c) = &state.pre_gate_commit {
830 let _ = writeln!(s, " committed mechanical fixes @ {}", short(c));
831 }
832 }
833
834 match state.gate_status() {
838 GateStatus::NotRun => {}
839 GateStatus::PassedWithNoCommands => {
840 let _ = writeln!(s, "\n{}", bold("gate"));
841 let _ = writeln!(s, " {} no gate commands configured", green("pass"));
842 }
843 GateStatus::Passed | GateStatus::Failed => {
844 let _ = writeln!(s, "\n{}", bold("gate"));
845 for o in &state.gate {
846 let _ = writeln!(
847 s,
848 " {} {}",
849 if o.ok() { green("pass") } else { red("FAIL") },
850 o.command
851 );
852 if !o.ok() {
853 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
854 }
855 }
856 }
857 }
858
859 if let Some(m) = &state.merge {
860 let _ = writeln!(s, "\n{}", bold("merge"));
861 if m.mode == MergeMode::None {
862 let _ = writeln!(
866 s,
867 " mode None {}",
868 cyan("not landed — nothing to do by design")
869 );
870 if let Some(w) = state.winner() {
871 let _ = writeln!(
872 s,
873 " branch {} still exists, unmerged into {}",
874 w.branch, state.base_branch
875 );
876 }
877 let _ = writeln!(
883 s,
884 " rebase onto {} before merging by hand{}",
885 state.base_branch,
886 if state.config.merge.style == MergeStyle::Squash {
887 ", and pass an explicit commit message — a squash merge \
888 otherwise inherits the candidate's placeholder subject"
889 } else {
890 ""
891 }
892 );
893 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
894 } else {
895 let _ = writeln!(
896 s,
897 " mode {:?} {}\n {}",
898 m.mode,
899 if m.ok {
900 green("ok")
901 } else {
902 yellow("not merged")
903 },
904 m.detail.lines().next().unwrap_or("")
905 );
906 }
907 }
908
909 if !state.leaks.is_empty() {
910 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
911 for l in &state.leaks {
912 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
913 }
914 }
915
916 if let Some(w) = state.winner()
917 && !w.folded
918 {
919 let _ = writeln!(
920 s,
921 "\n{} {}\n branch {}",
922 bold("winner worktree"),
923 w.worktree.display(),
924 w.branch
925 );
926 }
927 s.push_str(&jobs_section(state));
928 s
929}
930
931pub fn active_seats(state: &RunState, live: Liveness) -> String {
947 if state.active.is_empty() {
948 return String::new();
949 }
950 let mut s = String::new();
951 let _ = writeln!(s, "\n{}", bold("running now"));
952 let now = jiff::Timestamp::now();
953 match live {
954 Liveness::Live => {}
955 Liveness::Dead => {
956 let _ = writeln!(
957 s,
958 " {}",
959 yellow(
960 "no live daemon claims this run right now — likely left behind by a killed process"
961 )
962 );
963 }
964 Liveness::Unknown => {
965 let overrun = if state.active_all_overrun(now) {
971 " — every active seat has already run past its own timeout budget"
972 } else {
973 ""
974 };
975 let _ = writeln!(
976 s,
977 " {}",
978 yellow(&format!(
979 "whether a process is still driving this run could not be confirmed{overrun}"
980 ))
981 );
982 }
983 }
984 for (seat, a) in state.seats_active() {
985 let retry = if a.attempt > 0 {
986 format!(" retry {}", a.attempt)
987 } else {
988 String::new()
989 };
990 let _ = writeln!(
991 s,
992 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
993 seat,
994 a.node,
995 a.elapsed_secs(now),
996 a.remaining_secs(now),
997 a.timeout_secs
998 );
999 }
1000 for (task, a) in state.tasks_active() {
1001 let retry = if a.attempt > 0 {
1002 format!(" retry {}", a.attempt)
1003 } else {
1004 String::new()
1005 };
1006 let progress = match (a.index, a.total) {
1007 (Some(i), Some(t)) => format!(" ({i}/{t})"),
1008 _ => String::new(),
1009 };
1010 let _ = writeln!(
1011 s,
1012 " {:<12} {:<12}{retry}{progress} {}s elapsed, {}s left of {}s",
1013 task,
1014 a.node,
1015 a.elapsed_secs(now),
1016 a.remaining_secs(now),
1017 a.timeout_secs
1018 );
1019 if let Some(command) = &a.command {
1020 let _ = writeln!(s, " {command}");
1021 }
1022 }
1023 s
1024}
1025
1026pub fn stats(stats: &Stats) -> String {
1028 let t = &stats.totals;
1029 let mut s = String::new();
1030 let _ = writeln!(s, "{}", bold("runs"));
1031 let _ = writeln!(
1032 s,
1033 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1034 t.runs,
1035 t.merged,
1036 t.ready,
1037 t.blocked,
1038 t.failed,
1039 t.completion_rate()
1040 );
1041 if t.tallied > 0 {
1042 let _ = writeln!(
1043 s,
1044 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1045 {} of those changed a mind, {} converged to unanimous",
1046 t.tallied,
1047 t.split,
1048 t.split_rate(),
1049 t.deliberated,
1050 t.minds_changed,
1051 t.converged
1052 );
1053 }
1054
1055 if !stats.agents.is_empty() {
1056 let _ = writeln!(
1057 s,
1058 "\n{}",
1059 bold("implementation (relative, on this workload)")
1060 );
1061 let _ = writeln!(
1062 s,
1063 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1064 "agent", "won", "entered", "rate", "empty"
1065 );
1066 for a in &stats.agents {
1067 let _ = writeln!(
1068 s,
1069 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
1070 a.agent,
1071 a.wins,
1072 a.entered,
1073 a.win_rate(),
1074 a.empty
1075 );
1076 }
1077 }
1078
1079 if !stats.reviewers.is_empty() {
1080 let _ = writeln!(s, "\n{}", bold("review"));
1081 let _ = writeln!(
1082 s,
1083 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1084 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1085 );
1086 for r in &stats.reviewers {
1087 let _ = writeln!(
1088 s,
1089 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1090 r.agent,
1091 r.rounds,
1092 r.submitted,
1093 r.adopted_per_round(),
1094 r.precision(),
1095 r.unique_rate(),
1096 r.timeout_rate()
1097 );
1098 }
1099 }
1100
1101 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1102 let _ = writeln!(s, "\n{}", bold("verification"));
1103 let _ = writeln!(
1104 s,
1105 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1106 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1107 stats.e2e.rounds,
1108 stats.e2e.failures,
1109 stats.e2e.sole_detections,
1110 stats.e2e.sole_rate(),
1111 stats.e2e.deferred
1112 );
1113 }
1114 s
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120 use crate::config::Config;
1121 use crate::run::{
1122 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1123 Tally,
1124 };
1125 use std::collections::BTreeMap;
1126 use std::path::PathBuf;
1127 use std::sync::{Mutex, MutexGuard};
1128
1129 static SERIAL: Mutex<()> = Mutex::new(());
1131
1132 fn plain() -> MutexGuard<'static, ()> {
1133 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1134 set_color(false);
1135 guard
1136 }
1137
1138 fn state() -> RunState {
1139 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
1144 let mut s = RunState::new(
1145 PathBuf::from("/repo"),
1146 "main".to_owned(),
1147 "abcdef1234".to_owned(),
1148 "add retries to the uploader".to_owned(),
1149 Config::default(),
1150 );
1151 s.candidates = vec![Candidate {
1152 index: 0,
1153 label: 'A',
1154 agent: "opus".to_owned(),
1155 branch: "magi/x/A".to_owned(),
1156 worktree: PathBuf::from("/wt/A"),
1157 summary: String::new(),
1158 stat: String::new(),
1159 files: 3,
1160 commits: 2,
1161 empty: false,
1162 failed: None,
1163 verified_noop: None,
1164 duration_ms: 42_000,
1165 folded: false,
1166 }];
1167 s.tally = Some(Tally {
1168 first_choice: BTreeMap::from([('A', 3)]),
1169 borda: BTreeMap::new(),
1170 winner: 'A',
1171 rankings: 3,
1172 unanimous_initial: true,
1173 deliberated: false,
1174 changed_votes: 0,
1175 unanimous_final: true,
1176 tie_break: None,
1177 judges: 3,
1178 present: 3,
1179 quorum: 2,
1180 met_quorum: true,
1181 uncontested: None,
1182 });
1183 s
1184 }
1185
1186 #[test]
1187 fn run_report_names_the_winner_and_its_author() {
1188 let _guard = plain();
1189 let text = run(&state());
1190 assert!(text.contains("<- winner"), "{text}");
1191 assert!(text.contains("opus"));
1192 assert!(text.contains("3 files, 2 commits"));
1193 assert!(text.contains("winner A"));
1194 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1195 }
1196
1197 #[test]
1198 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1199 let _guard = plain();
1205 let mut s = state();
1206 s.candidates[0].summary =
1207 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1208 .to_owned();
1209 let text = run(&s);
1210 assert!(
1211 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1212 "{text}"
1213 );
1214 }
1215
1216 #[test]
1217 fn a_verified_noop_run_does_not_read_as_a_failure() {
1218 let _guard = plain();
1222 let mut s = state();
1223 s.status = RunStatus::VerifiedNoop;
1224 s.tally = None;
1225 s.candidates = vec![Candidate {
1226 index: 0,
1227 label: 'A',
1228 agent: "opus".to_owned(),
1229 branch: "magi/x/A".to_owned(),
1230 worktree: PathBuf::from("/wt/A"),
1231 summary: String::new(),
1232 stat: String::new(),
1233 files: 0,
1234 commits: 0,
1235 empty: true,
1236 failed: None,
1237 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1238 duration_ms: 9_000,
1239 folded: false,
1240 }];
1241 let text = run(&s);
1242 assert!(
1243 text.contains("agent-verified no-op"),
1244 "the status and the candidate flag must both say so: {text}"
1245 );
1246 assert!(
1247 text.contains("already fixed by b32cfc4"),
1248 "the evidence itself must be readable, not just the verdict: {text}"
1249 );
1250 assert!(
1251 !text.to_lowercase().contains("failed"),
1252 "a verified no-op must never read as the failure it is not: {text}"
1253 );
1254 }
1255
1256 #[test]
1257 fn colour_is_emitted_only_when_enabled() {
1258 let _guard = plain();
1259 set_color(true);
1260 let coloured = run(&state());
1261 set_color(false);
1262 let plain = run(&state());
1263 assert!(coloured.contains('\x1b'));
1264 assert!(!plain.contains('\x1b'));
1265 assert!(coloured.len() > plain.len());
1266 }
1267
1268 #[test]
1269 fn list_line_is_single_line() {
1270 let _guard = plain();
1271 let l = line(&state());
1272 assert_eq!(l.lines().count(), 1);
1273 assert!(l.contains("add retries"));
1274 assert!(l.contains("win A (opus)"));
1275 }
1276
1277 #[test]
1278 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1279 let _guard = plain();
1280 let mut s = state();
1281 s.tally = Some(Tally {
1282 first_choice: BTreeMap::from([('A', 0)]),
1283 borda: BTreeMap::new(),
1284 winner: 'A',
1285 rankings: 0,
1286 unanimous_initial: false,
1287 deliberated: false,
1288 changed_votes: 0,
1289 unanimous_final: false,
1290 tie_break: None,
1291 judges: 0,
1292 present: 0,
1293 quorum: 0,
1294 met_quorum: true,
1295 uncontested: Some(
1296 "only candidate A produced a usable change; no panel was asked".to_owned(),
1297 ),
1298 });
1299 let text = run(&s);
1300 assert!(
1301 !text.contains("0/3"),
1302 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1303 );
1304 assert!(!text.contains("no usable ranking"), "{text}");
1305 assert!(!text.contains("still split"), "{text}");
1306 assert!(!text.contains("BELOW QUORUM"), "{text}");
1307 assert!(
1308 text.contains("not needed"),
1309 "the report must say judging was skipped, not silent: {text}"
1310 );
1311 assert!(text.contains("winner A"));
1312 }
1313
1314 #[test]
1315 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1316 let _guard = plain();
1317 let mut s = state();
1318 s.tally = Some(Tally {
1319 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1320 borda: BTreeMap::new(),
1321 winner: 'A',
1322 rankings: 1,
1323 unanimous_initial: false,
1324 deliberated: false,
1325 changed_votes: 0,
1326 unanimous_final: false,
1327 tie_break: None,
1328 judges: 3,
1329 present: 1,
1330 quorum: 2,
1331 met_quorum: false,
1332 uncontested: None,
1333 });
1334 let text = run(&s);
1335 assert!(text.contains("1/3"), "{text}");
1336 assert!(
1337 text.contains("BELOW QUORUM"),
1338 "a real collapse must still be flagged: {text}"
1339 );
1340 assert!(
1341 !text.contains("not needed"),
1342 "a collapsed panel must not be described as one that was never asked: {text}"
1343 );
1344 }
1345
1346 #[test]
1347 fn a_mode_none_merge_does_not_read_as_landed() {
1348 let _guard = plain();
1349 let mut s = state();
1350 s.merge = Some(MergeOutcome {
1351 mode: crate::config::MergeMode::None,
1352 ok: true,
1353 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1354 });
1355 let text = run(&s);
1356 assert!(
1357 !text.contains(" ok"),
1358 "mode none must not be shown as a landed merge: {text}"
1359 );
1360 assert!(text.contains("not landed"), "{text}");
1361 assert!(
1362 text.contains("branch magi/x/A"),
1363 "the report must say what's left behind: {text}"
1364 );
1365 assert!(
1366 text.contains("rebase"),
1367 "the report must point at the hand-landing steps: {text}"
1368 );
1369 assert!(
1370 !text.contains("placeholder subject"),
1371 "the default merge style is `merge`, which never inherits a \
1372 placeholder subject, so the squash caveat must not appear: {text}"
1373 );
1374 }
1375
1376 #[test]
1377 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1378 let _guard = plain();
1379 let mut s = state();
1380 s.config.merge.style = MergeStyle::Squash;
1381 s.merge = Some(MergeOutcome {
1382 mode: crate::config::MergeMode::None,
1383 ok: true,
1384 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1385 retries\""
1386 .to_owned(),
1387 });
1388 let text = run(&s);
1389 assert!(
1390 text.contains("placeholder subject"),
1391 "a squash-style manual merge must warn about the missing message: {text}"
1392 );
1393 assert!(text.contains("--squash"), "{text}");
1394 }
1395
1396 #[test]
1397 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1398 let _guard = plain();
1399 let mut s = state();
1400 s.status = RunStatus::Ready;
1401 s.merge = Some(MergeOutcome {
1402 mode: crate::config::MergeMode::None,
1403 ok: true,
1404 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1405 });
1406
1407 let list = line(&s);
1408 assert!(
1409 !list.contains(" ready "),
1410 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1411 );
1412 assert!(list.contains("no-op by design"), "{list}");
1413
1414 let full = run(&s);
1415 assert!(
1416 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1417 "the header line of `magi show` must not say plain ready either: {full}"
1418 );
1419 assert!(full.contains("no-op by design"), "{full}");
1420 }
1421
1422 #[test]
1423 fn an_ordinary_ready_run_still_reads_as_ready() {
1424 let _guard = plain();
1425 let mut s = state();
1426 s.status = RunStatus::Ready;
1427 s.merge = Some(MergeOutcome {
1431 mode: crate::config::MergeMode::Pr,
1432 ok: false,
1433 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1434 });
1435
1436 let list = line(&s);
1437 assert!(list.contains("ready"), "{list}");
1438 assert!(!list.contains("no-op by design"), "{list}");
1439 }
1440
1441 #[test]
1442 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1443 let _guard = plain();
1444 let mut s = state();
1445 s.tally = Some(Tally {
1446 first_choice: BTreeMap::from([('A', 0)]),
1447 borda: BTreeMap::new(),
1448 winner: 'A',
1449 rankings: 0,
1450 unanimous_initial: false,
1451 deliberated: false,
1452 changed_votes: 0,
1453 unanimous_final: false,
1454 tie_break: None,
1455 judges: 0,
1456 present: 0,
1457 quorum: 0,
1458 met_quorum: true,
1459 uncontested: Some("only candidate A produced a usable change".to_owned()),
1460 });
1461 let l = line(&s);
1462 assert!(
1463 !l.contains("judges") && !l.contains("quorum"),
1464 "an uncontested run must not carry the same badge a short panel gets: {l}"
1465 );
1466 }
1467
1468 #[test]
1469 fn long_instructions_are_elided() {
1470 let _guard = plain();
1471 let mut s = state();
1472 s.instruction = "x".repeat(200);
1473 assert!(line(&s).contains('…'));
1474 }
1475
1476 #[test]
1477 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1478 let _guard = plain();
1479 let mut lost = state();
1480 lost.reviews = vec![ReviewRound {
1481 round: 1,
1482 head: "abc1234".to_owned(),
1483 verified_head: None,
1484 verified_at: None,
1485 reviews: Vec::new(),
1486 e2e: Vec::new(),
1487 verify_retried: false,
1488 e2e_deferred: false,
1489 e2e_defer_reason: None,
1490 fix: Some(FixRecord {
1491 agent: "opus".to_owned(),
1492 addressed: Vec::new(),
1493 rejected: Vec::new(),
1494 notes: String::new(),
1495 committed: true,
1496 failed: Some("timed out".to_owned()),
1497 duration_ms: 0,
1498 continuation: None,
1499 }),
1500 blocking: 3,
1501 answered: 0,
1502 expected: 0,
1503 clean: false,
1504 progressed: false,
1505 vote_split: false,
1506 reconsideration: Vec::new(),
1507 verdict: None,
1508 }];
1509 let text = run(&lost);
1510 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1511 assert!(
1512 !text.contains("0 addressed"),
1513 "a lost report must never read as `0 addressed`: {text}"
1514 );
1515
1516 let mut rejected_all = state();
1517 rejected_all.reviews = vec![ReviewRound {
1518 round: 1,
1519 head: "abc1234".to_owned(),
1520 verified_head: None,
1521 verified_at: None,
1522 reviews: Vec::new(),
1523 e2e: Vec::new(),
1524 verify_retried: false,
1525 e2e_deferred: false,
1526 e2e_defer_reason: None,
1527 fix: Some(FixRecord {
1528 agent: "opus".to_owned(),
1529 addressed: Vec::new(),
1530 rejected: Vec::new(),
1531 notes: String::new(),
1532 committed: true,
1533 failed: None,
1534 duration_ms: 0,
1535 continuation: None,
1536 }),
1537 blocking: 3,
1538 answered: 0,
1539 expected: 0,
1540 clean: false,
1541 progressed: false,
1542 vote_split: false,
1543 reconsideration: Vec::new(),
1544 verdict: None,
1545 }];
1546 let text2 = run(&rejected_all);
1547 assert!(
1548 text2.contains("0 addressed / 0 rejected"),
1549 "a round the fixer actually reported on keeps the count: {text2}"
1550 );
1551 }
1552
1553 #[test]
1554 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1555 use crate::run::ReviewRevoteRecord;
1556 use crate::verdict::ReviewVote;
1557
1558 let _guard = plain();
1559 let mut s = state();
1560 s.reviews = vec![ReviewRound {
1561 round: 1,
1562 head: "abc1234".to_owned(),
1563 verified_head: None,
1564 verified_at: None,
1565 reviews: vec![
1566 ReviewRecord {
1567 attempts: 0,
1568 reviewer: 1,
1569 agent: "alpha".to_owned(),
1570 summary: String::new(),
1571 findings: Vec::new(),
1572 vote: Some(ReviewVote::Approve),
1573 failed: None,
1574 duration_ms: 0,
1575 },
1576 ReviewRecord {
1577 attempts: 0,
1578 reviewer: 2,
1579 agent: "beta".to_owned(),
1580 summary: String::new(),
1581 findings: Vec::new(),
1582 vote: Some(ReviewVote::Reject),
1583 failed: None,
1584 duration_ms: 0,
1585 },
1586 ],
1587 e2e: Vec::new(),
1588 verify_retried: false,
1589 e2e_deferred: false,
1590 e2e_defer_reason: None,
1591 fix: None,
1592 blocking: 0,
1593 answered: 2,
1594 expected: 2,
1595 clean: false,
1596 progressed: false,
1597 vote_split: true,
1598 reconsideration: vec![ReviewRevoteRecord {
1599 reviewer: 2,
1600 agent: "beta".to_owned(),
1601 vote: Some(ReviewVote::ApproveWithFindings),
1602 reason: "the other seat's read holds up".to_owned(),
1603 failed: None,
1604 }],
1605 verdict: Some(ReviewVote::ApproveWithFindings),
1606 }];
1607 let text = run(&s);
1608 assert!(text.contains("review-1 vote"), "{text}");
1609 assert!(text.contains("review-2 vote"), "{text}");
1610 assert!(text.contains("panel split"), "{text}");
1611 assert!(text.contains("reconsideration"), "{text}");
1612 assert!(text.contains("the other seat's read holds up"), "{text}");
1613 }
1614
1615 #[test]
1616 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1617 let _guard = plain();
1623 let mut s = state();
1624 s.reviews = vec![ReviewRound {
1625 round: 1,
1626 head: "abc1234".to_owned(),
1627 verified_head: None,
1628 verified_at: None,
1629 reviews: vec![
1630 ReviewRecord {
1631 attempts: 0,
1632 reviewer: 1,
1633 agent: "alpha".to_owned(),
1634 summary: String::new(),
1635 findings: Vec::new(),
1636 vote: None,
1637 failed: None,
1638 duration_ms: 0,
1639 },
1640 ReviewRecord {
1641 attempts: 0,
1642 reviewer: 2,
1643 agent: "beta".to_owned(),
1644 summary: String::new(),
1645 findings: Vec::new(),
1646 vote: None,
1647 failed: Some("agent timed out".to_owned()),
1648 duration_ms: 0,
1649 },
1650 ],
1651 e2e: Vec::new(),
1652 verify_retried: false,
1653 e2e_deferred: false,
1654 e2e_defer_reason: None,
1655 fix: Some(FixRecord {
1656 agent: "opus".to_owned(),
1657 addressed: Vec::new(),
1658 rejected: Vec::new(),
1659 notes: String::new(),
1660 committed: true,
1661 failed: Some("timed out".to_owned()),
1662 duration_ms: 0,
1663 continuation: None,
1664 }),
1665 blocking: 0,
1666 answered: 1,
1667 expected: 2,
1668 clean: false,
1669 progressed: true,
1670 vote_split: false,
1671 reconsideration: Vec::new(),
1672 verdict: None,
1673 }];
1674 let text = run(&s);
1675 assert!(text.contains("incomplete"), "{text}");
1676 assert!(text.contains("1/2 reviewers answered"), "{text}");
1677 assert!(text.contains("review-2: agent timed out"), "{text}");
1678 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1679 assert!(
1680 !text.contains("clean"),
1681 "a round missing half its panel must never render as clean: {text}"
1682 );
1683 }
1684
1685 #[test]
1686 fn a_build_failure_is_not_reported_as_a_test_failure() {
1687 let _guard = plain();
1688 let mut s = state();
1689 s.reviews = vec![ReviewRound {
1690 round: 1,
1691 head: "abc1234".to_owned(),
1692 verified_head: None,
1693 verified_at: None,
1694 reviews: Vec::new(),
1695 e2e: vec![CommandOutcome {
1696 command: "cargo test".to_owned(),
1697 code: Some(1),
1698 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1699 duration_ms: 100,
1700 resource_blocked: false,
1701 }],
1702 verify_retried: true,
1703 e2e_deferred: false,
1704 e2e_defer_reason: None,
1705 fix: None,
1706 blocking: 0,
1707 answered: 0,
1708 expected: 0,
1709 clean: false,
1710 progressed: false,
1711 vote_split: false,
1712 reconsideration: Vec::new(),
1713 verdict: None,
1714 }];
1715 let text = run(&s);
1716 assert!(text.contains("could not run"), "{text}");
1717 assert!(text.contains("retried once"), "{text}");
1718 assert!(!text.contains("e2e RED"), "{text}");
1719 }
1720
1721 #[test]
1722 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
1723 let _guard = plain();
1724 let mut s = state();
1725 s.reviews = vec![ReviewRound {
1726 round: 1,
1727 head: "abc1234".to_owned(),
1728 verified_head: None,
1729 verified_at: None,
1730 reviews: Vec::new(),
1731 e2e: vec![CommandOutcome {
1732 command: "(waiting for the shared build cache)".to_owned(),
1733 code: None,
1734 output_tail: "held by run x node e2e seat e2e".to_owned(),
1735 duration_ms: 100,
1736 resource_blocked: true,
1737 }],
1738 verify_retried: false,
1739 e2e_deferred: false,
1740 e2e_defer_reason: None,
1741 fix: None,
1742 blocking: 0,
1743 answered: 0,
1744 expected: 0,
1745 clean: false,
1746 progressed: false,
1747 vote_split: false,
1748 reconsideration: Vec::new(),
1749 verdict: None,
1750 }];
1751 let text = run(&s);
1752 assert!(text.contains("shared build cache unavailable"), "{text}");
1753 assert!(!text.contains("e2e RED"), "{text}");
1754 assert!(!text.contains("build/link failure"), "{text}");
1755 }
1756
1757 #[test]
1758 fn a_round_with_more_than_one_e2e_command_names_each_one() {
1759 let _guard = plain();
1764 let mut s = state();
1765 s.reviews = vec![ReviewRound {
1766 round: 1,
1767 head: "abc1234".to_owned(),
1768 verified_head: Some("abc1234".to_owned()),
1769 verified_at: Some(jiff::Timestamp::now()),
1770 reviews: Vec::new(),
1771 e2e: vec![
1772 CommandOutcome {
1773 command: "cargo test --locked --all-targets".to_owned(),
1774 code: Some(0),
1775 output_tail: String::new(),
1776 duration_ms: 0,
1777 resource_blocked: false,
1778 },
1779 CommandOutcome {
1780 command: "cargo make check".to_owned(),
1781 code: Some(1),
1782 output_tail: "clippy: unused import".to_owned(),
1783 duration_ms: 0,
1784 resource_blocked: false,
1785 },
1786 ],
1787 verify_retried: false,
1788 e2e_deferred: false,
1789 e2e_defer_reason: None,
1790 fix: None,
1791 blocking: 0,
1792 answered: 0,
1793 expected: 0,
1794 clean: false,
1795 progressed: false,
1796 vote_split: false,
1797 reconsideration: Vec::new(),
1798 verdict: None,
1799 }];
1800 let text = run(&s);
1801 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
1802 assert!(text.contains("cargo make check"), "{text}");
1803 assert!(text.contains("clippy: unused import"), "{text}");
1804 }
1805
1806 #[test]
1807 fn a_declined_finding_shows_its_reason() {
1808 use crate::verdict::{Finding, Rejection, Severity};
1809
1810 let _guard = plain();
1811 let mut s = state();
1812 s.status = RunStatus::Ready;
1813 s.reviews = vec![ReviewRound {
1814 round: 1,
1815 head: "deadbee".to_owned(),
1816 verified_head: None,
1817 verified_at: None,
1818 reviews: vec![ReviewRecord {
1819 attempts: 0,
1820 reviewer: 1,
1821 agent: "alpha".to_owned(),
1822 summary: String::new(),
1823 findings: vec![Finding {
1824 id: "R1-1-1".to_owned(),
1825 severity: Severity::Major,
1826 file: None,
1827 line: None,
1828 title: "still open".to_owned(),
1829 detail: String::new(),
1830 }],
1831 vote: None,
1832 failed: None,
1833 duration_ms: 0,
1834 }],
1835 e2e: vec![CommandOutcome {
1836 command: "cargo test".to_owned(),
1837 code: Some(0),
1838 output_tail: String::new(),
1839 duration_ms: 0,
1840 resource_blocked: false,
1841 }],
1842 verify_retried: false,
1843 e2e_deferred: false,
1844 e2e_defer_reason: None,
1845 fix: Some(FixRecord {
1846 agent: "alpha".to_owned(),
1847 addressed: Vec::new(),
1848 rejected: vec![Rejection {
1849 id: "R1-1-2".to_owned(),
1850 why: "cannot be triggered from any caller".to_owned(),
1851 }],
1852 notes: String::new(),
1853 committed: true,
1854 failed: None,
1855 duration_ms: 0,
1856 continuation: None,
1857 }),
1858 blocking: 1,
1859 answered: 1,
1860 expected: 1,
1861 clean: false,
1862 progressed: true,
1863 vote_split: false,
1864 reconsideration: Vec::new(),
1865 verdict: None,
1866 }];
1867
1868 let text = run(&s);
1869 assert!(text.contains("R1-1-2"), "{text}");
1870 assert!(text.contains("cannot be triggered"), "{text}");
1871 assert!(text.contains("still open"), "{text}");
1872 assert!(
1873 text.contains("handed off"),
1874 "a mergeable run with an open round must say so: {text}"
1875 );
1876 }
1877
1878 #[test]
1879 fn a_failing_gate_command_shows_its_output() {
1880 let _guard = plain();
1881 let mut s = state();
1882 s.status = RunStatus::Blocked;
1883 s.gate = vec![CommandOutcome {
1884 command: "cargo make check".to_owned(),
1885 code: Some(101),
1886 output_tail: "error[E0308]: mismatched types".to_owned(),
1887 duration_ms: 0,
1888 resource_blocked: false,
1889 }];
1890 s.gate_ran = true;
1891
1892 let text = run(&s);
1893 assert!(text.contains("mismatched types"), "{text}");
1894 }
1895
1896 #[test]
1897 fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
1898 let _guard = plain();
1899 let mut s = state();
1900 s.status = RunStatus::Ready;
1901 s.gate_ran = true;
1902 assert!(s.gate.is_empty());
1903
1904 let text = run(&s);
1905 assert!(
1906 text.contains("gate") && text.contains("no gate commands configured"),
1907 "a run gated on nothing must say so, not read as if the gate never ran: {text}"
1908 );
1909 }
1910
1911 #[test]
1912 fn a_gate_that_has_not_run_yet_shows_nothing() {
1913 let _guard = plain();
1914 let s = state();
1915 assert!(!s.gate_ran);
1916 assert!(s.gate.is_empty());
1917
1918 let text = run(&s);
1919 assert!(
1920 !text.contains("no gate commands configured"),
1921 "an unattempted gate must not be shown as a pass: {text}"
1922 );
1923 }
1924
1925 #[test]
1926 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1927 let _guard = plain();
1928 let mut s = state();
1929 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1930 let text = active_seats(&s, Liveness::Live);
1931 assert!(text.contains("running now"));
1932 assert!(text.contains("judge-2"));
1933 assert!(text.contains("judge"));
1934 assert!(!text.contains("no live daemon"), "{text}");
1935 assert!(!text.contains("could not be confirmed"), "{text}");
1936 }
1937
1938 #[test]
1939 fn active_seats_flags_a_leftover_from_a_dead_process() {
1940 let _guard = plain();
1941 let mut s = state();
1942 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1943 let text = active_seats(&s, Liveness::Dead);
1944 assert!(
1945 text.contains("no live daemon"),
1946 "a stale entry must not read as running: {text}"
1947 );
1948 }
1949
1950 #[test]
1951 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
1952 let _guard = plain();
1953 let mut s = state();
1954 s.status = RunStatus::Reviewing;
1955 let text = line_with_liveness(&s, Liveness::Dead);
1956 assert!(text.contains("STALE"), "{text}");
1957 assert!(text.contains("resume required"), "{text}");
1958 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
1959 }
1960
1961 #[test]
1966 fn active_seats_reports_uncertainty_without_claiming_death() {
1967 let _guard = plain();
1968 let mut s = state();
1969 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
1970 let text = active_seats(&s, Liveness::Unknown);
1971 assert!(
1972 text.contains("could not be confirmed"),
1973 "an unproven state must read as uncertain, not dead: {text}"
1974 );
1975 assert!(!text.contains("no live daemon"), "{text}");
1976 }
1977
1978 #[test]
1979 fn active_seats_is_empty_when_nothing_is_running() {
1980 let _guard = plain();
1981 assert_eq!(active_seats(&state(), Liveness::Live), "");
1982 }
1983
1984 #[test]
1990 fn active_seats_shows_a_running_verify_task_and_its_command() {
1991 let _guard = plain();
1992 let mut s = state();
1993 s.task_command(
1994 "e2e",
1995 "verify",
1996 0,
1997 "cargo test",
1998 2,
1999 3,
2000 std::time::Duration::from_secs(600),
2001 );
2002 let text = active_seats(&s, Liveness::Live);
2003 assert!(text.contains("e2e"), "{text}");
2004 assert!(text.contains("(2/3)"), "{text}");
2005 assert!(text.contains("cargo test"), "{text}");
2006 }
2007
2008 #[test]
2009 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2010 let _guard = plain();
2011 assert!(!run(&state()).contains("background jobs"));
2014 }
2015
2016 #[test]
2017 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2018 let _guard = plain();
2019 let mut s = state();
2020 s.config.agents.push(crate::config::AgentSpec {
2021 id: "codex-one".to_owned(),
2022 kind: crate::config::AgentKind::Codex,
2023 model: None,
2024 command: vec!["codex".to_owned()],
2025 extra_args: Vec::new(),
2026 env: BTreeMap::new(),
2027 prompt_delivery: None,
2028 });
2029 let text = run(&s);
2030 assert!(
2031 text.contains("background jobs"),
2032 "a run that could report this must not read the same as one that never could: \
2033 {text}"
2034 );
2035 assert!(text.contains("no completed command evidence yet"));
2036 }
2037
2038 #[test]
2039 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2040 let _guard = plain();
2041 let mut s = state();
2042 s.jobs = vec![
2043 crate::run::JobRecord {
2044 node: "implement".to_owned(),
2045 round: None,
2046 seat: "impl-A".to_owned(),
2047 id: "item49".to_owned(),
2048 description: "cargo test --test graph_cached_gate".to_owned(),
2049 checked_at: jiff::Timestamp::now(),
2050 status: crate::run::JobStatus::Completed,
2051 exit_code: Some(0),
2052 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2053 source: "codex".to_owned(),
2054 },
2055 crate::run::JobRecord {
2056 node: "fix".to_owned(),
2057 round: None,
2058 seat: "impl-A".to_owned(),
2059 id: "item52".to_owned(),
2060 description: "cargo test --test graph_split".to_owned(),
2061 checked_at: jiff::Timestamp::now(),
2062 status: crate::run::JobStatus::Failed,
2063 exit_code: Some(101),
2064 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2065 source: "codex".to_owned(),
2066 },
2067 crate::run::JobRecord {
2068 node: "fix".to_owned(),
2069 round: None,
2070 seat: "impl-A".to_owned(),
2071 id: "item60".to_owned(),
2072 description: "cargo build".to_owned(),
2073 checked_at: jiff::Timestamp::now(),
2074 status: crate::run::JobStatus::Unknown,
2075 exit_code: None,
2076 result_summary: String::new(),
2077 source: "codex".to_owned(),
2078 },
2079 ];
2080 let text = run(&s);
2081 assert!(text.contains("background jobs"));
2082 assert!(text.contains("item49"));
2083 assert!(text.contains("item52"));
2084 assert!(text.contains("item60"));
2085 assert!(text.contains("completed"));
2089 assert!(text.contains("failed"));
2090 assert!(text.contains("unknown"));
2091 assert!(text.contains("adapter coverage"));
2093 }
2094
2095 #[test]
2096 fn a_jobs_own_round_is_shown_when_known() {
2097 let _guard = plain();
2098 let mut s = state();
2099 s.jobs = vec![crate::run::JobRecord {
2100 node: "review".to_owned(),
2101 round: Some(2),
2102 seat: "review-1".to_owned(),
2103 id: "item9".to_owned(),
2104 description: "cargo test --test graph_cached_gate".to_owned(),
2105 checked_at: jiff::Timestamp::now(),
2106 status: crate::run::JobStatus::Completed,
2107 exit_code: Some(0),
2108 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2109 source: "codex".to_owned(),
2110 }];
2111 let text = run(&s);
2112 assert!(
2113 text.contains("round 2"),
2114 "the round this seat's own command ran in must be visible, distinct from magi's \
2115 own recorded verify: {text}"
2116 );
2117 }
2118
2119 #[test]
2120 fn stats_table_renders_without_runs() {
2121 let _guard = plain();
2122 let text = stats(&Stats::default());
2123 assert!(text.contains("0 total"));
2124 assert!(!text.contains("implementation"));
2125 }
2126}