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.reviews.is_empty() {
490 let _ = writeln!(s, "\n{}", bold("review + verification"));
491 for r in &state.reviews {
492 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
493 let e2e = match r.e2e_status() {
501 E2eStatus::NotConfigured => dim("no e2e"),
502 E2eStatus::Deferred => yellow(&format!(
503 "e2e deferred{}",
504 r.e2e_defer_reason
505 .as_deref()
506 .map(|why| format!(" ({why})"))
507 .unwrap_or_default()
508 )),
509 E2eStatus::Passed => green("e2e green"),
510 E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
511 yellow("e2e could not run (build/link failure)")
512 }
513 E2eStatus::Failed => red("e2e RED"),
514 E2eStatus::ResourceBlocked => {
519 yellow("e2e could not run (shared build cache unavailable)")
520 }
521 };
522 let e2e = if r.verify_retried {
523 format!("{e2e}, retried once")
524 } else {
525 e2e
526 };
527 let status = if r.incomplete() {
531 yellow("incomplete")
532 } else if r.clean {
533 green("clean")
534 } else {
535 yellow("open")
536 };
537 let panel = if r.incomplete() {
541 let missing: Vec<String> = r
542 .reviews
543 .iter()
544 .filter_map(|x| {
545 x.failed
546 .as_ref()
547 .map(|why| format!("review-{}: {why}", x.reviewer))
548 })
549 .collect();
550 format!(
551 " {}/{} reviewers answered ({})",
552 r.answered,
553 r.expected,
554 missing.join(", ")
555 )
556 } else {
557 String::new()
558 };
559 let verdict = r.verdict.map_or(String::new(), |v| {
565 format!(
566 ", verdict {}{}",
567 vote_tag(v),
568 if r.vote_split { " (panel split)" } else { "" }
569 )
570 });
571 let _ = writeln!(
572 s,
573 " round {} {} @ {}{}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
574 r.round,
575 status,
576 short(&r.head),
577 r.verified_head.as_ref().map_or(String::new(), |head| {
578 format!(
579 " (verified @ {}{})",
580 short(head),
581 r.verified_at.map_or(String::new(), |t| format!(
582 " on {}",
583 t.to_zoned(jiff::tz::TimeZone::system())
584 .strftime("%Y-%m-%d %H:%M:%S")
585 ))
586 )
587 }),
588 r.blocking,
589 r.fix.as_ref().map_or(String::new(), |f| {
590 let tree = if r.progressed {
591 green("changed")
592 } else {
593 yellow("unchanged")
594 };
595 let cont = f
596 .continuation
597 .as_ref()
598 .map_or(String::new(), continuation_note);
599 match &f.failed {
600 Some(reason) => format!(
605 " fix: {}, tree {tree}{}{cont}",
606 yellow(&format!("adoption report lost ({reason})")),
607 if f.committed {
608 String::new()
609 } else {
610 red(" (NO COMMIT)")
611 }
612 ),
613 None => format!(
614 " fix: {} addressed / {} rejected, tree {tree}{}{cont}",
615 f.addressed.len(),
616 f.rejected.len(),
617 if f.committed {
618 String::new()
619 } else {
620 red(" (NO COMMIT)")
621 }
622 ),
623 }
624 })
625 );
626 for o in &r.e2e {
633 let label = if o.resource_blocked {
634 yellow("blocked")
635 } else if o.ok() {
636 green("pass")
637 } else {
638 red("FAIL")
639 };
640 let _ = writeln!(s, " {label} {}", o.command);
641 if !o.ok() {
642 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
643 }
644 }
645 for rec in &r.reviews {
646 if let Some(vote) = rec.vote {
647 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
648 }
649 for f in &rec.findings {
650 let adopted = r
651 .fix
652 .as_ref()
653 .is_some_and(|fix| fix.addressed.contains(&f.id));
654 let _ = writeln!(
655 s,
656 " {} [{:?}] {}{}",
657 dim(&f.id),
658 f.severity,
659 f.title,
660 if adopted {
661 green(" fixed")
662 } else {
663 String::new()
664 }
665 );
666 }
667 }
668 if let Some(fix) = &r.fix {
669 for rej in &fix.rejected {
670 let _ = writeln!(
671 s,
672 " {} {}: {}",
673 dim(&rej.id),
674 yellow("declined"),
675 rej.why
676 );
677 }
678 }
679 if !r.reconsideration.is_empty() {
683 let _ = writeln!(s, " {}", dim("reconsideration:"));
684 for rv in &r.reconsideration {
685 match rv.vote {
686 Some(v) => {
687 let _ = writeln!(
688 s,
689 " review-{} -> {} {}",
690 rv.reviewer,
691 vote_tag(v),
692 rv.reason
693 );
694 }
695 None => {
696 let _ = writeln!(
697 s,
698 " review-{} -> {}",
699 rv.reviewer,
700 red(&format!(
701 "no revote ({})",
702 rv.failed.as_deref().unwrap_or("unknown")
703 ))
704 );
705 }
706 }
707 }
708 }
709 }
710 if state.handed_off_with_open_findings() {
711 let _ = writeln!(
712 s,
713 "\n {}",
714 yellow(&format!(
715 "handed off with {} finding(s) still open — gate and e2e were green; \
716 see above for what a person should still look at",
717 state.open_findings().len()
718 ))
719 );
720 }
721 }
722
723 if !state.operator_fixes.is_empty() {
724 let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
725 for (i, req) in state.operator_fixes.iter().enumerate() {
726 let _ = writeln!(
727 s,
728 " [{}] {} finding(s) at {}{}",
729 i + 1,
730 req.findings.len(),
731 req.requested_at
732 .to_zoned(jiff::tz::TimeZone::system())
733 .strftime("%Y-%m-%d %H:%M:%S"),
734 if req.stale {
735 yellow(" stale head, --allow-stale used")
736 } else {
737 String::new()
738 }
739 );
740 let _ = writeln!(s, " reason: {}", req.reason);
741 for f in &req.findings {
742 let outcome = match &f.outcome {
743 OperatorFixOutcome::Pending => yellow("pending"),
744 OperatorFixOutcome::Addressed => green("addressed"),
745 OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
746 OperatorFixOutcome::Unreported => {
747 red("unreported — no adoption report came back")
748 }
749 };
750 let _ = writeln!(
751 s,
752 " {} [{:?}] {} {outcome}",
753 dim(&f.id),
754 f.severity,
755 f.title
756 );
757 }
758 match &req.follow_up_review_run {
759 Some(id) => {
760 let _ = writeln!(s, " re-verified by run {id}");
761 }
762 None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
763 let _ = writeln!(
764 s,
765 " {}",
766 red("committed, but the follow-up review could not be opened")
767 );
768 }
769 None => {
770 let _ = writeln!(s, " no change committed; nothing to re-verify");
771 }
772 }
773 }
774 }
775
776 if let Some(bs) = &state.base_sync {
777 let _ = writeln!(s, "\n{}", bold("base sync"));
778 let status = if let Some(c) = &bs.conflict {
779 red(&format!("conflict: {}", first_line(c)))
780 } else if bs.behind == 0 {
781 green("in sync")
782 } else {
783 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
784 };
785 let _ = writeln!(
786 s,
787 " {} @ {} {status}{}",
788 state.base_branch,
789 short(&bs.tip),
790 if bs.attempts > 0 {
791 format!(" ({} rebase attempt(s))", bs.attempts)
792 } else {
793 String::new()
794 }
795 );
796 }
797
798 match state.gate_status() {
802 GateStatus::NotRun => {}
803 GateStatus::PassedWithNoCommands => {
804 let _ = writeln!(s, "\n{}", bold("gate"));
805 let _ = writeln!(s, " {} no gate commands configured", green("pass"));
806 }
807 GateStatus::Passed | GateStatus::Failed => {
808 let _ = writeln!(s, "\n{}", bold("gate"));
809 for o in &state.gate {
810 let _ = writeln!(
811 s,
812 " {} {}",
813 if o.ok() { green("pass") } else { red("FAIL") },
814 o.command
815 );
816 if !o.ok() {
817 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
818 }
819 }
820 }
821 }
822
823 if let Some(m) = &state.merge {
824 let _ = writeln!(s, "\n{}", bold("merge"));
825 if m.mode == MergeMode::None {
826 let _ = writeln!(
830 s,
831 " mode None {}",
832 cyan("not landed — nothing to do by design")
833 );
834 if let Some(w) = state.winner() {
835 let _ = writeln!(
836 s,
837 " branch {} still exists, unmerged into {}",
838 w.branch, state.base_branch
839 );
840 }
841 let _ = writeln!(
847 s,
848 " rebase onto {} before merging by hand{}",
849 state.base_branch,
850 if state.config.merge.style == MergeStyle::Squash {
851 ", and pass an explicit commit message — a squash merge \
852 otherwise inherits the candidate's placeholder subject"
853 } else {
854 ""
855 }
856 );
857 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
858 } else {
859 let _ = writeln!(
860 s,
861 " mode {:?} {}\n {}",
862 m.mode,
863 if m.ok {
864 green("ok")
865 } else {
866 yellow("not merged")
867 },
868 m.detail.lines().next().unwrap_or("")
869 );
870 }
871 }
872
873 if !state.leaks.is_empty() {
874 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
875 for l in &state.leaks {
876 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
877 }
878 }
879
880 if let Some(w) = state.winner()
881 && !w.folded
882 {
883 let _ = writeln!(
884 s,
885 "\n{} {}\n branch {}",
886 bold("winner worktree"),
887 w.worktree.display(),
888 w.branch
889 );
890 }
891 s.push_str(&jobs_section(state));
892 s
893}
894
895pub fn active_seats(state: &RunState, live: Liveness) -> String {
911 if state.active.is_empty() {
912 return String::new();
913 }
914 let mut s = String::new();
915 let _ = writeln!(s, "\n{}", bold("running now"));
916 let now = jiff::Timestamp::now();
917 match live {
918 Liveness::Live => {}
919 Liveness::Dead => {
920 let _ = writeln!(
921 s,
922 " {}",
923 yellow(
924 "no live daemon claims this run right now — likely left behind by a killed process"
925 )
926 );
927 }
928 Liveness::Unknown => {
929 let overrun = if state.active_all_overrun(now) {
935 " — every active seat has already run past its own timeout budget"
936 } else {
937 ""
938 };
939 let _ = writeln!(
940 s,
941 " {}",
942 yellow(&format!(
943 "whether a process is still driving this run could not be confirmed{overrun}"
944 ))
945 );
946 }
947 }
948 for (seat, a) in state.seats_active() {
949 let retry = if a.attempt > 0 {
950 format!(" retry {}", a.attempt)
951 } else {
952 String::new()
953 };
954 let _ = writeln!(
955 s,
956 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
957 seat,
958 a.node,
959 a.elapsed_secs(now),
960 a.remaining_secs(now),
961 a.timeout_secs
962 );
963 }
964 for (task, a) in state.tasks_active() {
965 let retry = if a.attempt > 0 {
966 format!(" retry {}", a.attempt)
967 } else {
968 String::new()
969 };
970 let progress = match (a.index, a.total) {
971 (Some(i), Some(t)) => format!(" ({i}/{t})"),
972 _ => String::new(),
973 };
974 let _ = writeln!(
975 s,
976 " {:<12} {:<12}{retry}{progress} {}s elapsed, {}s left of {}s",
977 task,
978 a.node,
979 a.elapsed_secs(now),
980 a.remaining_secs(now),
981 a.timeout_secs
982 );
983 if let Some(command) = &a.command {
984 let _ = writeln!(s, " {command}");
985 }
986 }
987 s
988}
989
990pub fn stats(stats: &Stats) -> String {
992 let t = &stats.totals;
993 let mut s = String::new();
994 let _ = writeln!(s, "{}", bold("runs"));
995 let _ = writeln!(
996 s,
997 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
998 t.runs,
999 t.merged,
1000 t.ready,
1001 t.blocked,
1002 t.failed,
1003 t.completion_rate()
1004 );
1005 if t.tallied > 0 {
1006 let _ = writeln!(
1007 s,
1008 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1009 {} of those changed a mind, {} converged to unanimous",
1010 t.tallied,
1011 t.split,
1012 t.split_rate(),
1013 t.deliberated,
1014 t.minds_changed,
1015 t.converged
1016 );
1017 }
1018
1019 if !stats.agents.is_empty() {
1020 let _ = writeln!(
1021 s,
1022 "\n{}",
1023 bold("implementation (relative, on this workload)")
1024 );
1025 let _ = writeln!(
1026 s,
1027 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1028 "agent", "won", "entered", "rate", "empty"
1029 );
1030 for a in &stats.agents {
1031 let _ = writeln!(
1032 s,
1033 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
1034 a.agent,
1035 a.wins,
1036 a.entered,
1037 a.win_rate(),
1038 a.empty
1039 );
1040 }
1041 }
1042
1043 if !stats.reviewers.is_empty() {
1044 let _ = writeln!(s, "\n{}", bold("review"));
1045 let _ = writeln!(
1046 s,
1047 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1048 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1049 );
1050 for r in &stats.reviewers {
1051 let _ = writeln!(
1052 s,
1053 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1054 r.agent,
1055 r.rounds,
1056 r.submitted,
1057 r.adopted_per_round(),
1058 r.precision(),
1059 r.unique_rate(),
1060 r.timeout_rate()
1061 );
1062 }
1063 }
1064
1065 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1066 let _ = writeln!(s, "\n{}", bold("verification"));
1067 let _ = writeln!(
1068 s,
1069 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1070 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1071 stats.e2e.rounds,
1072 stats.e2e.failures,
1073 stats.e2e.sole_detections,
1074 stats.e2e.sole_rate(),
1075 stats.e2e.deferred
1076 );
1077 }
1078 s
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use crate::config::Config;
1085 use crate::run::{
1086 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1087 Tally,
1088 };
1089 use std::collections::BTreeMap;
1090 use std::path::PathBuf;
1091 use std::sync::{Mutex, MutexGuard};
1092
1093 static SERIAL: Mutex<()> = Mutex::new(());
1095
1096 fn plain() -> MutexGuard<'static, ()> {
1097 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1098 set_color(false);
1099 guard
1100 }
1101
1102 fn state() -> RunState {
1103 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
1108 let mut s = RunState::new(
1109 PathBuf::from("/repo"),
1110 "main".to_owned(),
1111 "abcdef1234".to_owned(),
1112 "add retries to the uploader".to_owned(),
1113 Config::default(),
1114 );
1115 s.candidates = vec![Candidate {
1116 index: 0,
1117 label: 'A',
1118 agent: "opus".to_owned(),
1119 branch: "magi/x/A".to_owned(),
1120 worktree: PathBuf::from("/wt/A"),
1121 summary: String::new(),
1122 stat: String::new(),
1123 files: 3,
1124 commits: 2,
1125 empty: false,
1126 failed: None,
1127 verified_noop: None,
1128 duration_ms: 42_000,
1129 folded: false,
1130 }];
1131 s.tally = Some(Tally {
1132 first_choice: BTreeMap::from([('A', 3)]),
1133 borda: BTreeMap::new(),
1134 winner: 'A',
1135 rankings: 3,
1136 unanimous_initial: true,
1137 deliberated: false,
1138 changed_votes: 0,
1139 unanimous_final: true,
1140 tie_break: None,
1141 judges: 3,
1142 present: 3,
1143 quorum: 2,
1144 met_quorum: true,
1145 uncontested: None,
1146 });
1147 s
1148 }
1149
1150 #[test]
1151 fn run_report_names_the_winner_and_its_author() {
1152 let _guard = plain();
1153 let text = run(&state());
1154 assert!(text.contains("<- winner"), "{text}");
1155 assert!(text.contains("opus"));
1156 assert!(text.contains("3 files, 2 commits"));
1157 assert!(text.contains("winner A"));
1158 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1159 }
1160
1161 #[test]
1162 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1163 let _guard = plain();
1169 let mut s = state();
1170 s.candidates[0].summary =
1171 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1172 .to_owned();
1173 let text = run(&s);
1174 assert!(
1175 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1176 "{text}"
1177 );
1178 }
1179
1180 #[test]
1181 fn a_verified_noop_run_does_not_read_as_a_failure() {
1182 let _guard = plain();
1186 let mut s = state();
1187 s.status = RunStatus::VerifiedNoop;
1188 s.tally = None;
1189 s.candidates = vec![Candidate {
1190 index: 0,
1191 label: 'A',
1192 agent: "opus".to_owned(),
1193 branch: "magi/x/A".to_owned(),
1194 worktree: PathBuf::from("/wt/A"),
1195 summary: String::new(),
1196 stat: String::new(),
1197 files: 0,
1198 commits: 0,
1199 empty: true,
1200 failed: None,
1201 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1202 duration_ms: 9_000,
1203 folded: false,
1204 }];
1205 let text = run(&s);
1206 assert!(
1207 text.contains("agent-verified no-op"),
1208 "the status and the candidate flag must both say so: {text}"
1209 );
1210 assert!(
1211 text.contains("already fixed by b32cfc4"),
1212 "the evidence itself must be readable, not just the verdict: {text}"
1213 );
1214 assert!(
1215 !text.to_lowercase().contains("failed"),
1216 "a verified no-op must never read as the failure it is not: {text}"
1217 );
1218 }
1219
1220 #[test]
1221 fn colour_is_emitted_only_when_enabled() {
1222 let _guard = plain();
1223 set_color(true);
1224 let coloured = run(&state());
1225 set_color(false);
1226 let plain = run(&state());
1227 assert!(coloured.contains('\x1b'));
1228 assert!(!plain.contains('\x1b'));
1229 assert!(coloured.len() > plain.len());
1230 }
1231
1232 #[test]
1233 fn list_line_is_single_line() {
1234 let _guard = plain();
1235 let l = line(&state());
1236 assert_eq!(l.lines().count(), 1);
1237 assert!(l.contains("add retries"));
1238 assert!(l.contains("win A (opus)"));
1239 }
1240
1241 #[test]
1242 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1243 let _guard = plain();
1244 let mut s = state();
1245 s.tally = Some(Tally {
1246 first_choice: BTreeMap::from([('A', 0)]),
1247 borda: BTreeMap::new(),
1248 winner: 'A',
1249 rankings: 0,
1250 unanimous_initial: false,
1251 deliberated: false,
1252 changed_votes: 0,
1253 unanimous_final: false,
1254 tie_break: None,
1255 judges: 0,
1256 present: 0,
1257 quorum: 0,
1258 met_quorum: true,
1259 uncontested: Some(
1260 "only candidate A produced a usable change; no panel was asked".to_owned(),
1261 ),
1262 });
1263 let text = run(&s);
1264 assert!(
1265 !text.contains("0/3"),
1266 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1267 );
1268 assert!(!text.contains("no usable ranking"), "{text}");
1269 assert!(!text.contains("still split"), "{text}");
1270 assert!(!text.contains("BELOW QUORUM"), "{text}");
1271 assert!(
1272 text.contains("not needed"),
1273 "the report must say judging was skipped, not silent: {text}"
1274 );
1275 assert!(text.contains("winner A"));
1276 }
1277
1278 #[test]
1279 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1280 let _guard = plain();
1281 let mut s = state();
1282 s.tally = Some(Tally {
1283 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1284 borda: BTreeMap::new(),
1285 winner: 'A',
1286 rankings: 1,
1287 unanimous_initial: false,
1288 deliberated: false,
1289 changed_votes: 0,
1290 unanimous_final: false,
1291 tie_break: None,
1292 judges: 3,
1293 present: 1,
1294 quorum: 2,
1295 met_quorum: false,
1296 uncontested: None,
1297 });
1298 let text = run(&s);
1299 assert!(text.contains("1/3"), "{text}");
1300 assert!(
1301 text.contains("BELOW QUORUM"),
1302 "a real collapse must still be flagged: {text}"
1303 );
1304 assert!(
1305 !text.contains("not needed"),
1306 "a collapsed panel must not be described as one that was never asked: {text}"
1307 );
1308 }
1309
1310 #[test]
1311 fn a_mode_none_merge_does_not_read_as_landed() {
1312 let _guard = plain();
1313 let mut s = state();
1314 s.merge = Some(MergeOutcome {
1315 mode: crate::config::MergeMode::None,
1316 ok: true,
1317 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1318 });
1319 let text = run(&s);
1320 assert!(
1321 !text.contains(" ok"),
1322 "mode none must not be shown as a landed merge: {text}"
1323 );
1324 assert!(text.contains("not landed"), "{text}");
1325 assert!(
1326 text.contains("branch magi/x/A"),
1327 "the report must say what's left behind: {text}"
1328 );
1329 assert!(
1330 text.contains("rebase"),
1331 "the report must point at the hand-landing steps: {text}"
1332 );
1333 assert!(
1334 !text.contains("placeholder subject"),
1335 "the default merge style is `merge`, which never inherits a \
1336 placeholder subject, so the squash caveat must not appear: {text}"
1337 );
1338 }
1339
1340 #[test]
1341 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1342 let _guard = plain();
1343 let mut s = state();
1344 s.config.merge.style = MergeStyle::Squash;
1345 s.merge = Some(MergeOutcome {
1346 mode: crate::config::MergeMode::None,
1347 ok: true,
1348 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1349 retries\""
1350 .to_owned(),
1351 });
1352 let text = run(&s);
1353 assert!(
1354 text.contains("placeholder subject"),
1355 "a squash-style manual merge must warn about the missing message: {text}"
1356 );
1357 assert!(text.contains("--squash"), "{text}");
1358 }
1359
1360 #[test]
1361 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1362 let _guard = plain();
1363 let mut s = state();
1364 s.status = RunStatus::Ready;
1365 s.merge = Some(MergeOutcome {
1366 mode: crate::config::MergeMode::None,
1367 ok: true,
1368 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1369 });
1370
1371 let list = line(&s);
1372 assert!(
1373 !list.contains(" ready "),
1374 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1375 );
1376 assert!(list.contains("no-op by design"), "{list}");
1377
1378 let full = run(&s);
1379 assert!(
1380 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1381 "the header line of `magi show` must not say plain ready either: {full}"
1382 );
1383 assert!(full.contains("no-op by design"), "{full}");
1384 }
1385
1386 #[test]
1387 fn an_ordinary_ready_run_still_reads_as_ready() {
1388 let _guard = plain();
1389 let mut s = state();
1390 s.status = RunStatus::Ready;
1391 s.merge = Some(MergeOutcome {
1395 mode: crate::config::MergeMode::Pr,
1396 ok: false,
1397 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1398 });
1399
1400 let list = line(&s);
1401 assert!(list.contains("ready"), "{list}");
1402 assert!(!list.contains("no-op by design"), "{list}");
1403 }
1404
1405 #[test]
1406 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1407 let _guard = plain();
1408 let mut s = state();
1409 s.tally = Some(Tally {
1410 first_choice: BTreeMap::from([('A', 0)]),
1411 borda: BTreeMap::new(),
1412 winner: 'A',
1413 rankings: 0,
1414 unanimous_initial: false,
1415 deliberated: false,
1416 changed_votes: 0,
1417 unanimous_final: false,
1418 tie_break: None,
1419 judges: 0,
1420 present: 0,
1421 quorum: 0,
1422 met_quorum: true,
1423 uncontested: Some("only candidate A produced a usable change".to_owned()),
1424 });
1425 let l = line(&s);
1426 assert!(
1427 !l.contains("judges") && !l.contains("quorum"),
1428 "an uncontested run must not carry the same badge a short panel gets: {l}"
1429 );
1430 }
1431
1432 #[test]
1433 fn long_instructions_are_elided() {
1434 let _guard = plain();
1435 let mut s = state();
1436 s.instruction = "x".repeat(200);
1437 assert!(line(&s).contains('…'));
1438 }
1439
1440 #[test]
1441 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1442 let _guard = plain();
1443 let mut lost = state();
1444 lost.reviews = vec![ReviewRound {
1445 round: 1,
1446 head: "abc1234".to_owned(),
1447 verified_head: None,
1448 verified_at: None,
1449 reviews: Vec::new(),
1450 e2e: Vec::new(),
1451 verify_retried: false,
1452 e2e_deferred: false,
1453 e2e_defer_reason: None,
1454 fix: Some(FixRecord {
1455 agent: "opus".to_owned(),
1456 addressed: Vec::new(),
1457 rejected: Vec::new(),
1458 notes: String::new(),
1459 committed: true,
1460 failed: Some("timed out".to_owned()),
1461 duration_ms: 0,
1462 continuation: None,
1463 }),
1464 blocking: 3,
1465 answered: 0,
1466 expected: 0,
1467 clean: false,
1468 progressed: false,
1469 vote_split: false,
1470 reconsideration: Vec::new(),
1471 verdict: None,
1472 }];
1473 let text = run(&lost);
1474 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1475 assert!(
1476 !text.contains("0 addressed"),
1477 "a lost report must never read as `0 addressed`: {text}"
1478 );
1479
1480 let mut rejected_all = state();
1481 rejected_all.reviews = vec![ReviewRound {
1482 round: 1,
1483 head: "abc1234".to_owned(),
1484 verified_head: None,
1485 verified_at: None,
1486 reviews: Vec::new(),
1487 e2e: Vec::new(),
1488 verify_retried: false,
1489 e2e_deferred: false,
1490 e2e_defer_reason: None,
1491 fix: Some(FixRecord {
1492 agent: "opus".to_owned(),
1493 addressed: Vec::new(),
1494 rejected: Vec::new(),
1495 notes: String::new(),
1496 committed: true,
1497 failed: None,
1498 duration_ms: 0,
1499 continuation: None,
1500 }),
1501 blocking: 3,
1502 answered: 0,
1503 expected: 0,
1504 clean: false,
1505 progressed: false,
1506 vote_split: false,
1507 reconsideration: Vec::new(),
1508 verdict: None,
1509 }];
1510 let text2 = run(&rejected_all);
1511 assert!(
1512 text2.contains("0 addressed / 0 rejected"),
1513 "a round the fixer actually reported on keeps the count: {text2}"
1514 );
1515 }
1516
1517 #[test]
1518 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1519 use crate::run::ReviewRevoteRecord;
1520 use crate::verdict::ReviewVote;
1521
1522 let _guard = plain();
1523 let mut s = state();
1524 s.reviews = vec![ReviewRound {
1525 round: 1,
1526 head: "abc1234".to_owned(),
1527 verified_head: None,
1528 verified_at: None,
1529 reviews: vec![
1530 ReviewRecord {
1531 attempts: 0,
1532 reviewer: 1,
1533 agent: "alpha".to_owned(),
1534 summary: String::new(),
1535 findings: Vec::new(),
1536 vote: Some(ReviewVote::Approve),
1537 failed: None,
1538 duration_ms: 0,
1539 },
1540 ReviewRecord {
1541 attempts: 0,
1542 reviewer: 2,
1543 agent: "beta".to_owned(),
1544 summary: String::new(),
1545 findings: Vec::new(),
1546 vote: Some(ReviewVote::Reject),
1547 failed: None,
1548 duration_ms: 0,
1549 },
1550 ],
1551 e2e: Vec::new(),
1552 verify_retried: false,
1553 e2e_deferred: false,
1554 e2e_defer_reason: None,
1555 fix: None,
1556 blocking: 0,
1557 answered: 2,
1558 expected: 2,
1559 clean: false,
1560 progressed: false,
1561 vote_split: true,
1562 reconsideration: vec![ReviewRevoteRecord {
1563 reviewer: 2,
1564 agent: "beta".to_owned(),
1565 vote: Some(ReviewVote::ApproveWithFindings),
1566 reason: "the other seat's read holds up".to_owned(),
1567 failed: None,
1568 }],
1569 verdict: Some(ReviewVote::ApproveWithFindings),
1570 }];
1571 let text = run(&s);
1572 assert!(text.contains("review-1 vote"), "{text}");
1573 assert!(text.contains("review-2 vote"), "{text}");
1574 assert!(text.contains("panel split"), "{text}");
1575 assert!(text.contains("reconsideration"), "{text}");
1576 assert!(text.contains("the other seat's read holds up"), "{text}");
1577 }
1578
1579 #[test]
1580 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1581 let _guard = plain();
1587 let mut s = state();
1588 s.reviews = vec![ReviewRound {
1589 round: 1,
1590 head: "abc1234".to_owned(),
1591 verified_head: None,
1592 verified_at: None,
1593 reviews: vec![
1594 ReviewRecord {
1595 attempts: 0,
1596 reviewer: 1,
1597 agent: "alpha".to_owned(),
1598 summary: String::new(),
1599 findings: Vec::new(),
1600 vote: None,
1601 failed: None,
1602 duration_ms: 0,
1603 },
1604 ReviewRecord {
1605 attempts: 0,
1606 reviewer: 2,
1607 agent: "beta".to_owned(),
1608 summary: String::new(),
1609 findings: Vec::new(),
1610 vote: None,
1611 failed: Some("agent timed out".to_owned()),
1612 duration_ms: 0,
1613 },
1614 ],
1615 e2e: Vec::new(),
1616 verify_retried: false,
1617 e2e_deferred: false,
1618 e2e_defer_reason: None,
1619 fix: Some(FixRecord {
1620 agent: "opus".to_owned(),
1621 addressed: Vec::new(),
1622 rejected: Vec::new(),
1623 notes: String::new(),
1624 committed: true,
1625 failed: Some("timed out".to_owned()),
1626 duration_ms: 0,
1627 continuation: None,
1628 }),
1629 blocking: 0,
1630 answered: 1,
1631 expected: 2,
1632 clean: false,
1633 progressed: true,
1634 vote_split: false,
1635 reconsideration: Vec::new(),
1636 verdict: None,
1637 }];
1638 let text = run(&s);
1639 assert!(text.contains("incomplete"), "{text}");
1640 assert!(text.contains("1/2 reviewers answered"), "{text}");
1641 assert!(text.contains("review-2: agent timed out"), "{text}");
1642 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1643 assert!(
1644 !text.contains("clean"),
1645 "a round missing half its panel must never render as clean: {text}"
1646 );
1647 }
1648
1649 #[test]
1650 fn a_build_failure_is_not_reported_as_a_test_failure() {
1651 let _guard = plain();
1652 let mut s = state();
1653 s.reviews = vec![ReviewRound {
1654 round: 1,
1655 head: "abc1234".to_owned(),
1656 verified_head: None,
1657 verified_at: None,
1658 reviews: Vec::new(),
1659 e2e: vec![CommandOutcome {
1660 command: "cargo test".to_owned(),
1661 code: Some(1),
1662 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1663 duration_ms: 100,
1664 resource_blocked: false,
1665 }],
1666 verify_retried: true,
1667 e2e_deferred: false,
1668 e2e_defer_reason: None,
1669 fix: None,
1670 blocking: 0,
1671 answered: 0,
1672 expected: 0,
1673 clean: false,
1674 progressed: false,
1675 vote_split: false,
1676 reconsideration: Vec::new(),
1677 verdict: None,
1678 }];
1679 let text = run(&s);
1680 assert!(text.contains("could not run"), "{text}");
1681 assert!(text.contains("retried once"), "{text}");
1682 assert!(!text.contains("e2e RED"), "{text}");
1683 }
1684
1685 #[test]
1686 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_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: "(waiting for the shared build cache)".to_owned(),
1697 code: None,
1698 output_tail: "held by run x node e2e seat e2e".to_owned(),
1699 duration_ms: 100,
1700 resource_blocked: true,
1701 }],
1702 verify_retried: false,
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("shared build cache unavailable"), "{text}");
1717 assert!(!text.contains("e2e RED"), "{text}");
1718 assert!(!text.contains("build/link failure"), "{text}");
1719 }
1720
1721 #[test]
1722 fn a_round_with_more_than_one_e2e_command_names_each_one() {
1723 let _guard = plain();
1728 let mut s = state();
1729 s.reviews = vec![ReviewRound {
1730 round: 1,
1731 head: "abc1234".to_owned(),
1732 verified_head: Some("abc1234".to_owned()),
1733 verified_at: Some(jiff::Timestamp::now()),
1734 reviews: Vec::new(),
1735 e2e: vec![
1736 CommandOutcome {
1737 command: "cargo test --locked --all-targets".to_owned(),
1738 code: Some(0),
1739 output_tail: String::new(),
1740 duration_ms: 0,
1741 resource_blocked: false,
1742 },
1743 CommandOutcome {
1744 command: "cargo make check".to_owned(),
1745 code: Some(1),
1746 output_tail: "clippy: unused import".to_owned(),
1747 duration_ms: 0,
1748 resource_blocked: false,
1749 },
1750 ],
1751 verify_retried: false,
1752 e2e_deferred: false,
1753 e2e_defer_reason: None,
1754 fix: None,
1755 blocking: 0,
1756 answered: 0,
1757 expected: 0,
1758 clean: false,
1759 progressed: false,
1760 vote_split: false,
1761 reconsideration: Vec::new(),
1762 verdict: None,
1763 }];
1764 let text = run(&s);
1765 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
1766 assert!(text.contains("cargo make check"), "{text}");
1767 assert!(text.contains("clippy: unused import"), "{text}");
1768 }
1769
1770 #[test]
1771 fn a_declined_finding_shows_its_reason() {
1772 use crate::verdict::{Finding, Rejection, Severity};
1773
1774 let _guard = plain();
1775 let mut s = state();
1776 s.status = RunStatus::Ready;
1777 s.reviews = vec![ReviewRound {
1778 round: 1,
1779 head: "deadbee".to_owned(),
1780 verified_head: None,
1781 verified_at: None,
1782 reviews: vec![ReviewRecord {
1783 attempts: 0,
1784 reviewer: 1,
1785 agent: "alpha".to_owned(),
1786 summary: String::new(),
1787 findings: vec![Finding {
1788 id: "R1-1-1".to_owned(),
1789 severity: Severity::Major,
1790 file: None,
1791 line: None,
1792 title: "still open".to_owned(),
1793 detail: String::new(),
1794 }],
1795 vote: None,
1796 failed: None,
1797 duration_ms: 0,
1798 }],
1799 e2e: vec![CommandOutcome {
1800 command: "cargo test".to_owned(),
1801 code: Some(0),
1802 output_tail: String::new(),
1803 duration_ms: 0,
1804 resource_blocked: false,
1805 }],
1806 verify_retried: false,
1807 e2e_deferred: false,
1808 e2e_defer_reason: None,
1809 fix: Some(FixRecord {
1810 agent: "alpha".to_owned(),
1811 addressed: Vec::new(),
1812 rejected: vec![Rejection {
1813 id: "R1-1-2".to_owned(),
1814 why: "cannot be triggered from any caller".to_owned(),
1815 }],
1816 notes: String::new(),
1817 committed: true,
1818 failed: None,
1819 duration_ms: 0,
1820 continuation: None,
1821 }),
1822 blocking: 1,
1823 answered: 1,
1824 expected: 1,
1825 clean: false,
1826 progressed: true,
1827 vote_split: false,
1828 reconsideration: Vec::new(),
1829 verdict: None,
1830 }];
1831
1832 let text = run(&s);
1833 assert!(text.contains("R1-1-2"), "{text}");
1834 assert!(text.contains("cannot be triggered"), "{text}");
1835 assert!(text.contains("still open"), "{text}");
1836 assert!(
1837 text.contains("handed off"),
1838 "a mergeable run with an open round must say so: {text}"
1839 );
1840 }
1841
1842 #[test]
1843 fn a_failing_gate_command_shows_its_output() {
1844 let _guard = plain();
1845 let mut s = state();
1846 s.status = RunStatus::Blocked;
1847 s.gate = vec![CommandOutcome {
1848 command: "cargo make check".to_owned(),
1849 code: Some(101),
1850 output_tail: "error[E0308]: mismatched types".to_owned(),
1851 duration_ms: 0,
1852 resource_blocked: false,
1853 }];
1854 s.gate_ran = true;
1855
1856 let text = run(&s);
1857 assert!(text.contains("mismatched types"), "{text}");
1858 }
1859
1860 #[test]
1861 fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
1862 let _guard = plain();
1863 let mut s = state();
1864 s.status = RunStatus::Ready;
1865 s.gate_ran = true;
1866 assert!(s.gate.is_empty());
1867
1868 let text = run(&s);
1869 assert!(
1870 text.contains("gate") && text.contains("no gate commands configured"),
1871 "a run gated on nothing must say so, not read as if the gate never ran: {text}"
1872 );
1873 }
1874
1875 #[test]
1876 fn a_gate_that_has_not_run_yet_shows_nothing() {
1877 let _guard = plain();
1878 let s = state();
1879 assert!(!s.gate_ran);
1880 assert!(s.gate.is_empty());
1881
1882 let text = run(&s);
1883 assert!(
1884 !text.contains("no gate commands configured"),
1885 "an unattempted gate must not be shown as a pass: {text}"
1886 );
1887 }
1888
1889 #[test]
1890 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1891 let _guard = plain();
1892 let mut s = state();
1893 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1894 let text = active_seats(&s, Liveness::Live);
1895 assert!(text.contains("running now"));
1896 assert!(text.contains("judge-2"));
1897 assert!(text.contains("judge"));
1898 assert!(!text.contains("no live daemon"), "{text}");
1899 assert!(!text.contains("could not be confirmed"), "{text}");
1900 }
1901
1902 #[test]
1903 fn active_seats_flags_a_leftover_from_a_dead_process() {
1904 let _guard = plain();
1905 let mut s = state();
1906 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1907 let text = active_seats(&s, Liveness::Dead);
1908 assert!(
1909 text.contains("no live daemon"),
1910 "a stale entry must not read as running: {text}"
1911 );
1912 }
1913
1914 #[test]
1915 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
1916 let _guard = plain();
1917 let mut s = state();
1918 s.status = RunStatus::Reviewing;
1919 let text = line_with_liveness(&s, Liveness::Dead);
1920 assert!(text.contains("STALE"), "{text}");
1921 assert!(text.contains("resume required"), "{text}");
1922 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
1923 }
1924
1925 #[test]
1930 fn active_seats_reports_uncertainty_without_claiming_death() {
1931 let _guard = plain();
1932 let mut s = state();
1933 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
1934 let text = active_seats(&s, Liveness::Unknown);
1935 assert!(
1936 text.contains("could not be confirmed"),
1937 "an unproven state must read as uncertain, not dead: {text}"
1938 );
1939 assert!(!text.contains("no live daemon"), "{text}");
1940 }
1941
1942 #[test]
1943 fn active_seats_is_empty_when_nothing_is_running() {
1944 let _guard = plain();
1945 assert_eq!(active_seats(&state(), Liveness::Live), "");
1946 }
1947
1948 #[test]
1954 fn active_seats_shows_a_running_verify_task_and_its_command() {
1955 let _guard = plain();
1956 let mut s = state();
1957 s.task_command(
1958 "e2e",
1959 "verify",
1960 0,
1961 "cargo test",
1962 2,
1963 3,
1964 std::time::Duration::from_secs(600),
1965 );
1966 let text = active_seats(&s, Liveness::Live);
1967 assert!(text.contains("e2e"), "{text}");
1968 assert!(text.contains("(2/3)"), "{text}");
1969 assert!(text.contains("cargo test"), "{text}");
1970 }
1971
1972 #[test]
1973 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
1974 let _guard = plain();
1975 assert!(!run(&state()).contains("background jobs"));
1978 }
1979
1980 #[test]
1981 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
1982 let _guard = plain();
1983 let mut s = state();
1984 s.config.agents.push(crate::config::AgentSpec {
1985 id: "codex-one".to_owned(),
1986 kind: crate::config::AgentKind::Codex,
1987 model: None,
1988 command: vec!["codex".to_owned()],
1989 extra_args: Vec::new(),
1990 env: BTreeMap::new(),
1991 prompt_delivery: None,
1992 });
1993 let text = run(&s);
1994 assert!(
1995 text.contains("background jobs"),
1996 "a run that could report this must not read the same as one that never could: \
1997 {text}"
1998 );
1999 assert!(text.contains("no completed command evidence yet"));
2000 }
2001
2002 #[test]
2003 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2004 let _guard = plain();
2005 let mut s = state();
2006 s.jobs = vec![
2007 crate::run::JobRecord {
2008 node: "implement".to_owned(),
2009 round: None,
2010 seat: "impl-A".to_owned(),
2011 id: "item49".to_owned(),
2012 description: "cargo test --test graph_cached_gate".to_owned(),
2013 checked_at: jiff::Timestamp::now(),
2014 status: crate::run::JobStatus::Completed,
2015 exit_code: Some(0),
2016 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2017 source: "codex".to_owned(),
2018 },
2019 crate::run::JobRecord {
2020 node: "fix".to_owned(),
2021 round: None,
2022 seat: "impl-A".to_owned(),
2023 id: "item52".to_owned(),
2024 description: "cargo test --test graph_split".to_owned(),
2025 checked_at: jiff::Timestamp::now(),
2026 status: crate::run::JobStatus::Failed,
2027 exit_code: Some(101),
2028 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2029 source: "codex".to_owned(),
2030 },
2031 crate::run::JobRecord {
2032 node: "fix".to_owned(),
2033 round: None,
2034 seat: "impl-A".to_owned(),
2035 id: "item60".to_owned(),
2036 description: "cargo build".to_owned(),
2037 checked_at: jiff::Timestamp::now(),
2038 status: crate::run::JobStatus::Unknown,
2039 exit_code: None,
2040 result_summary: String::new(),
2041 source: "codex".to_owned(),
2042 },
2043 ];
2044 let text = run(&s);
2045 assert!(text.contains("background jobs"));
2046 assert!(text.contains("item49"));
2047 assert!(text.contains("item52"));
2048 assert!(text.contains("item60"));
2049 assert!(text.contains("completed"));
2053 assert!(text.contains("failed"));
2054 assert!(text.contains("unknown"));
2055 assert!(text.contains("adapter coverage"));
2057 }
2058
2059 #[test]
2060 fn a_jobs_own_round_is_shown_when_known() {
2061 let _guard = plain();
2062 let mut s = state();
2063 s.jobs = vec![crate::run::JobRecord {
2064 node: "review".to_owned(),
2065 round: Some(2),
2066 seat: "review-1".to_owned(),
2067 id: "item9".to_owned(),
2068 description: "cargo test --test graph_cached_gate".to_owned(),
2069 checked_at: jiff::Timestamp::now(),
2070 status: crate::run::JobStatus::Completed,
2071 exit_code: Some(0),
2072 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2073 source: "codex".to_owned(),
2074 }];
2075 let text = run(&s);
2076 assert!(
2077 text.contains("round 2"),
2078 "the round this seat's own command ran in must be visible, distinct from magi's \
2079 own recorded verify: {text}"
2080 );
2081 }
2082
2083 #[test]
2084 fn stats_table_renders_without_runs() {
2085 let _guard = plain();
2086 let text = stats(&Stats::default());
2087 assert!(text.contains("0 total"));
2088 assert!(!text.contains("implementation"));
2089 }
2090}