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