1use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::{MergeMode, MergeStyle};
16use crate::run::{
17 CommandOutcome, ContinuationOutcome, E2eStatus, GateStatus, JobStatus, Liveness,
18 OperatorFixOutcome, RunState, RunStatus, tail,
19};
20use crate::stats::{RepoStats, Stats};
21use crate::verdict::ReviewVote;
22
23static COLOR: AtomicBool = AtomicBool::new(true);
24
25pub fn set_color(on: bool) {
27 COLOR.store(on, Ordering::Relaxed);
28}
29
30fn paint(text: &str, code: &str) -> String {
31 if COLOR.load(Ordering::Relaxed) {
32 format!("\x1b[{code}m{text}\x1b[0m")
33 } else {
34 text.to_owned()
35 }
36}
37
38fn bold(t: &str) -> String {
39 paint(t, "1")
40}
41fn dim(t: &str) -> String {
42 paint(t, "2")
43}
44fn red(t: &str) -> String {
45 paint(t, "31")
46}
47fn green(t: &str) -> String {
48 paint(t, "32")
49}
50fn yellow(t: &str) -> String {
51 paint(t, "33")
52}
53fn cyan(t: &str) -> String {
54 paint(t, "36")
55}
56
57fn status_word(state: &RunState) -> String {
68 if state.unmerged_by_design() {
69 return cyan("unmerged (no-op by design)");
70 }
71 let text = state.status.display_label();
72 if state.needs_attention() {
73 return bold(&yellow(&format!("{text} - release needs a human")));
74 }
75 match state.status {
76 RunStatus::Merged => bold(&green(text)),
77 RunStatus::Ready => green(text),
78 RunStatus::Stalled => bold(&yellow(text)),
79 RunStatus::Blocked => yellow(text),
80 RunStatus::Failed => red(text),
81 RunStatus::VerifiedNoop => cyan(text),
85 RunStatus::Superseded => dim(text),
88 RunStatus::AlreadyInBase => dim(text),
91 _ => cyan(text),
92 }
93}
94
95fn vote_tag(vote: ReviewVote) -> String {
99 let text = vote.label();
100 match vote {
101 ReviewVote::Approve => green(text),
102 ReviewVote::ApproveWithFindings => yellow(text),
103 ReviewVote::Reject => red(text),
104 }
105}
106
107pub fn line(state: &RunState) -> String {
109 line_with_liveness(state, Liveness::Unknown)
110}
111
112pub fn line_with_liveness(state: &RunState, live: Liveness) -> String {
115 let winner = state
116 .tally
117 .as_ref()
118 .map_or("-".to_owned(), |t| t.winner.to_string());
119 let agent = state.winner().map_or("-", |c| c.agent.as_str());
120 let quorum = match state.tally.as_ref() {
123 Some(t) if !t.met_quorum => format!(
124 " {}",
125 bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
126 ),
127 Some(t) if t.present > 0 && t.present < t.judges => format!(
128 " {}",
129 yellow(&format!("judges {}/{}", t.present, t.judges))
130 ),
131 _ => String::new(),
132 };
133 let stale = if !state.status.done() && live == Liveness::Dead {
134 format!(
135 " {}",
136 bold(&yellow("STALE — driver exited; resume required"))
137 )
138 } else {
139 String::new()
140 };
141 let origin = state
144 .origin
145 .as_ref()
146 .map_or("origin unknown".to_owned(), crate::run::Origin::label);
147 format!(
148 "{} {:<20} {:>2}c {:>2}j win {} ({}){quorum}{stale} {} {}",
149 dim(&state.id),
150 status_word(state),
151 state.candidates.len(),
152 state.judgements.len(),
153 winner,
154 agent,
155 dim(&format!("[{origin}]")),
156 first_line(&state.instruction)
157 )
158}
159
160pub fn liveness_notice(state: &RunState, live: Liveness) -> String {
164 if !state.status.done() && live == Liveness::Dead {
165 format!(
166 "{}\n\n",
167 yellow("STALE — the process driving this run exited; resume it to continue.")
168 )
169 } else {
170 String::new()
171 }
172}
173
174pub fn superseded_notice(later: Option<&str>) -> String {
185 match later {
186 Some(later) => format!(
187 "{}\n\n",
188 yellow(&format!(
189 "SUPERSEDED — a later attempt at this task ({later}) replaced it; this record \
190 is kept but is not the one to act on."
191 ))
192 ),
193 None => String::new(),
194 }
195}
196
197fn first_line(text: &str) -> String {
198 let line = text.lines().next().unwrap_or_default();
199 if line.chars().count() > 68 {
200 format!("{}…", line.chars().take(67).collect::<String>())
201 } else {
202 line.to_owned()
203 }
204}
205
206fn short(commit: &str) -> String {
207 commit.chars().take(7).collect()
208}
209
210fn continuation_note(c: &crate::run::ContinuationRecord) -> String {
215 match c.outcome {
216 ContinuationOutcome::NotNeeded => String::new(),
217 ContinuationOutcome::Resumed => format!(" [resumed x{}]", c.attempts),
218 ContinuationOutcome::Exhausted => format!(" [continuation exhausted x{}]", c.attempts),
219 ContinuationOutcome::QuotaLost => " [continuation: quota]".to_owned(),
220 ContinuationOutcome::NoSession => " [no session to resume]".to_owned(),
221 }
222}
223
224fn jobs_section(state: &RunState) -> String {
245 let mut s = String::new();
246 if state.jobs.is_empty() {
247 if state
252 .config
253 .agents
254 .iter()
255 .any(|a| a.kind == crate::config::AgentKind::Codex)
256 {
257 let _ = writeln!(
258 s,
259 "\n{}",
260 dim(
261 "background jobs: no completed command evidence yet for this run (see \
262 active seats above for what is still mid-turn)"
263 )
264 );
265 }
266 return s;
267 }
268 let _ = writeln!(
269 s,
270 "\n{}",
271 bold("background jobs (from each seat's own CLI)")
272 );
273 let mut by_seat: std::collections::BTreeMap<(&str, &str), Vec<&crate::run::JobRecord>> =
274 std::collections::BTreeMap::new();
275 for j in &state.jobs {
276 by_seat
277 .entry((j.node.as_str(), j.seat.as_str()))
278 .or_default()
279 .push(j);
280 }
281 for ((node, seat), records) in by_seat {
282 let _ = writeln!(s, " {node}/{seat}");
283 for j in records {
284 let status = match j.status {
285 JobStatus::Completed => green("completed"),
286 JobStatus::Failed => red("failed"),
287 JobStatus::Unknown => yellow("unknown"),
288 };
289 let _ = writeln!(
290 s,
291 " {} {}{}{} checked {}",
292 dim(&j.id),
293 status,
294 j.exit_code
295 .map_or(String::new(), |c| format!(" (exit {c})")),
296 j.round.map_or(String::new(), |r| format!(" round {r}")),
297 j.checked_at
298 .to_zoned(jiff::tz::TimeZone::system())
299 .strftime("%Y-%m-%d %H:%M:%S")
300 );
301 let desc = first_line(&j.description);
302 if !desc.trim().is_empty() {
303 let _ = writeln!(s, " $ {desc}");
304 }
305 let summary = first_line(&j.result_summary);
306 if !summary.trim().is_empty() {
307 let _ = writeln!(s, " {}", dim(&summary));
308 }
309 }
310 }
311 let _ = writeln!(
312 s,
313 " {}",
314 dim(
315 "(adapter coverage: codex only today; other backends, and a command a CLI never \
316 reported finishing, leave no entry here — that is unknown, never \"nothing ran\")"
317 )
318 );
319 s
320}
321
322pub fn run(state: &RunState) -> String {
324 let mut s = String::new();
325 let _ = writeln!(
326 s,
327 "{} {} {}",
328 bold("magi run"),
329 bold(&state.id),
330 status_word(state)
331 );
332 let _ = writeln!(
333 s,
334 " repo {} ({} @ {})",
335 state.repo.display(),
336 state.base_branch,
337 short(&state.base_commit)
338 );
339 let _ = writeln!(s, " created {}", state.created_local());
340 let _ = writeln!(s, " task {}", first_line(&state.instruction));
341 let _ = writeln!(
342 s,
343 " origin {}",
344 crate::run::origin_label(state.origin.as_ref())
345 );
346 let _ = writeln!(s, " state {}", state.dir().display());
347
348 let _ = writeln!(s, "\n{}", bold("candidates"));
349 for c in &state.candidates {
350 let flag = match (&c.failed, c.empty, &c.verified_noop) {
351 (Some(e), _, _) => red(&format!("failed: {e}")),
352 (None, true, Some(_)) => cyan("agent-verified no-op (unconfirmed)"),
356 (None, true, None) => yellow("no change"),
357 _ => format!("{} files, {} commits", c.files, c.commits),
358 };
359 let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
360 bold(&green(" <- winner"))
361 } else {
362 String::new()
363 };
364 let _ = writeln!(
365 s,
366 " {} {:<12} {:<30} {:>5}s{}",
367 bold(&c.label.to_string()),
368 c.agent,
369 flag,
370 c.duration_ms / 1000,
371 crown
372 );
373 if let Some(evidence) = &c.verified_noop {
374 let _ = writeln!(s, " {}", dim(&first_line(evidence)));
375 } else if !c.summary.trim().is_empty() {
376 let _ = writeln!(s, " {}", dim(&first_line(&c.summary)));
385 }
386 }
387
388 if !state.judgements.is_empty() {
389 let _ = writeln!(s, "\n{}", bold("blind judging"));
390 for j in &state.judgements {
391 match &j.failed {
392 Some(e) => {
393 let _ = writeln!(
394 s,
395 " judge {} {}",
396 j.judge,
397 red(&format!("no ranking: {e}"))
398 );
399 }
400 None => {
401 let _ = writeln!(
402 s,
403 " judge {} {:<12} {} confidence {}",
404 j.judge,
405 j.agent,
406 bold(&j.ranking.iter().collect::<String>()),
407 j.confidence.map_or("-".to_owned(), |c| c.to_string())
408 );
409 }
410 }
411 }
412 }
413
414 if let Some(t) = &state.tally {
415 if t.deliberated {
416 let _ = writeln!(s, "\n{}", bold("deliberation"));
417 for round in &state.deliberation {
418 for turn in &round.turns {
419 let _ = writeln!(
420 s,
421 " r{} judge {} -> {}",
422 round.round,
423 turn.judge,
424 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
425 );
426 }
427 }
428 }
429
430 if !state.votes.is_empty() {
431 let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
432 for v in &state.votes {
433 let _ = writeln!(
434 s,
435 " judge {} {:<12} {}{}",
436 v.judge,
437 v.agent,
438 bold(&v.vote.unwrap_or('?').to_string()),
439 if v.changed {
440 yellow(" (changed after deliberation)")
441 } else {
442 String::new()
443 }
444 );
445 }
446 }
447
448 let _ = writeln!(s, "\n{}", bold("tally"));
449 match &t.uncontested {
455 Some(reason) => {
456 let _ = writeln!(
457 s,
458 " judging {}",
459 cyan(&format!("not needed — {reason}"))
460 );
461 }
462 None => {
463 let _ = writeln!(
464 s,
465 " judges {} present{}",
466 if t.met_quorum {
467 green(&format!("{}/{}", t.present, t.judges))
468 } else {
469 red(&format!("{}/{}", t.present, t.judges))
470 },
471 if t.quorum > 0 {
472 format!(" ({quorum} required)", quorum = t.quorum)
473 } else {
474 String::new()
475 }
476 );
477 if !t.met_quorum {
478 let _ = writeln!(
479 s,
480 " {}",
481 bold(&red("BELOW QUORUM — verdict is not trustworthy"))
482 );
483 }
484 let _ = writeln!(
485 s,
486 " first choice {}",
487 t.first_choice
488 .iter()
489 .map(|(k, v)| format!("{k}:{v}"))
490 .collect::<Vec<_>>()
491 .join(" ")
492 );
493 let _ = writeln!(
494 s,
495 " initial {}",
496 match (t.rankings, t.unanimous_initial) {
497 (0, _) => red("no usable ranking"),
498 (1, _) => yellow("one usable ranking - not a consensus"),
499 (_, true) => green("unanimous"),
500 (_, false) => yellow("split"),
501 }
502 );
503 let _ = writeln!(
504 s,
505 " after votes {} ({} judge(s) moved)",
506 if t.unanimous_final {
507 green("unanimous")
508 } else {
509 yellow("still split")
510 },
511 t.changed_votes
512 );
513 if let Some(tb) = &t.tie_break {
514 let _ = writeln!(s, " tie break {tb}");
515 }
516 }
517 }
518 if !state.quota.is_empty() {
519 let _ = writeln!(
520 s,
521 " rate limited {}",
522 state
523 .quota
524 .iter()
525 .map(|q| q.seat.as_str())
526 .collect::<Vec<_>>()
527 .join(", ")
528 );
529 }
530 let _ = writeln!(s, " winner {}", bold(&green(&t.winner.to_string())));
531 }
532
533 if !state.handovers.is_empty() {
534 let _ = writeln!(s, "\n{}", bold("seats handed over"));
535 for h in &state.handovers {
536 let _ = writeln!(
537 s,
538 " {} {} {} -> {} ({}: {})",
539 yellow("~"),
540 h.seat,
541 h.from,
542 h.to,
543 h.node,
544 h.reason
545 );
546 }
547 }
548
549 if !state.withheld.is_empty() {
550 let _ = writeln!(s, "\n{}", bold("withheld from commit"));
551 for w in &state.withheld {
552 let _ = writeln!(
553 s,
554 " {} {} ({} lockfile; the directory uses {})",
555 yellow("!"),
556 w.path,
557 w.manager,
558 w.kept_by
559 );
560 }
561 }
562
563 if let Some(r) = &state.recovery {
564 let _ = writeln!(s, "\n{}", bold("recovery"));
565 let _ = writeln!(s, " {}", r.summary());
566 if r.criteria_inherited {
567 let _ = writeln!(s, " the task's own completion criteria were kept");
568 }
569 match &r.repair {
570 crate::recovery::Repair::NotStarted => {}
571 crate::recovery::Repair::WaitingForRestore { .. } => {
572 let _ = writeln!(s, " repair: waiting to restore and retry");
573 }
574 crate::recovery::Repair::Started { .. } => {
575 let _ = writeln!(s, " repair: {}", yellow("unfinished"));
576 }
577 crate::recovery::Repair::Done {
578 committed, failed, ..
579 } => {
580 let _ = match failed {
581 Some(why) => writeln!(s, " repair: {} {why}", yellow("nothing kept:")),
582 None if *committed => writeln!(s, " repair: {}", green("committed")),
583 None => writeln!(s, " repair: no commit"),
584 };
585 }
586 }
587 }
588
589 if let Some(g) = &state.graft {
590 let _ = writeln!(s, "\n{}", bold("graft"));
591 let size = g.lines.map_or_else(
592 || " changed lines unavailable".to_owned(),
593 |n| format!(" {n} changed lines"),
594 );
595 let _ = match &g.outcome {
596 crate::run::GraftOutcome::Applied => writeln!(s, " {}{size}", green("applied")),
597 crate::run::GraftOutcome::WaitingForRestore => {
598 writeln!(s, " {}{size}", yellow("waiting to restore and retry"))
599 }
600 crate::run::GraftOutcome::Running => writeln!(s, " {}{size}", yellow("unfinished")),
601 crate::run::GraftOutcome::RolledBack(why) => {
602 writeln!(s, " {} {why}{size}", yellow("rolled back:"))
603 }
604 };
605 if let Some(a) = &g.agent {
606 let chain: Vec<String> = state
607 .handovers
608 .iter()
609 .filter(|h| h.node == "graft")
610 .map(|h| format!("{} -> {}", h.from, h.to))
611 .collect();
612 let via = if chain.is_empty() {
613 String::new()
614 } else {
615 format!(" (handovers: {})", chain.join(", "))
616 };
617 let _ = writeln!(s, " by {a}{via}");
618 }
619 if !g.requested.is_empty() {
620 let view = crate::report_view::build(state, Liveness::Unknown);
621 if let Some(crate::report_view::Section::Graft { graft, .. }) = view
622 .sections
623 .iter()
624 .find(|x| matches!(x, crate::report_view::Section::Graft { .. }))
625 {
626 let _ = writeln!(
627 s,
628 " requested {} confirmed adopted {} rejected {} unconfirmed {}",
629 graft.requested.len(),
630 graft.adopted_count,
631 graft.rejected_count,
632 graft.unconfirmed_count
633 );
634 }
635 }
636 if let Some(n) = g
637 .adoption
638 .as_ref()
639 .and_then(|a| a.notes.lines().find(|l| !l.trim().is_empty()))
640 {
641 let _ = writeln!(s, " fixer notes: {}", n.trim());
642 }
643 }
644
645 if !state.reviews.is_empty() {
646 let _ = writeln!(s, "\n{}", bold("review + verification"));
647 for r in &state.reviews {
648 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
649 let e2e = match r.e2e_status() {
657 E2eStatus::NotConfigured => dim("no e2e"),
658 E2eStatus::Deferred => yellow(&format!(
659 "e2e deferred{}",
660 r.e2e_defer_reason
661 .as_deref()
662 .map(|why| format!(" ({why})"))
663 .unwrap_or_default()
664 )),
665 E2eStatus::Passed => green("e2e green"),
666 E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
667 yellow("e2e could not run (build/link failure)")
668 }
669 E2eStatus::Failed => red("e2e RED"),
670 E2eStatus::ResourceBlocked => yellow("e2e could not run (resource unavailable)"),
675 };
676 let e2e = if r.verify_retried {
677 format!("{e2e}, retried once")
678 } else {
679 e2e
680 };
681 let status = if r.incomplete() {
685 yellow("incomplete")
686 } else if r.clean {
687 green("clean")
688 } else {
689 yellow("open")
690 };
691 let panel = if r.incomplete() {
695 let missing: Vec<String> = r
696 .reviews
697 .iter()
698 .filter_map(|x| {
699 x.failed
700 .as_ref()
701 .map(|why| format!("review-{}: {why}", x.reviewer))
702 })
703 .collect();
704 format!(
705 " {}/{} reviewers answered ({})",
706 r.answered,
707 r.expected,
708 missing.join(", ")
709 )
710 } else {
711 String::new()
712 };
713 let verdict = r.verdict.map_or(String::new(), |v| {
719 format!(
720 ", verdict {}{}",
721 vote_tag(v),
722 if r.vote_split { " (panel split)" } else { "" }
723 )
724 });
725 let _ = writeln!(
726 s,
727 " round {} {} @ {}{}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
728 r.round,
729 status,
730 short(&r.head),
731 r.verified_head.as_ref().map_or(String::new(), |head| {
732 format!(
733 " (verified @ {}{})",
734 short(head),
735 r.verified_at.map_or(String::new(), |t| format!(
736 " on {}",
737 t.to_zoned(jiff::tz::TimeZone::system())
738 .strftime("%Y-%m-%d %H:%M:%S")
739 ))
740 )
741 }),
742 r.blocking,
743 r.fix.as_ref().map_or(String::new(), |f| {
744 let tree = if r.progressed {
745 green("changed")
746 } else {
747 yellow("unchanged")
748 };
749 let cont = f
750 .continuation
751 .as_ref()
752 .map_or(String::new(), continuation_note);
753 match &f.failed {
754 Some(reason) => format!(
759 " fix: {}, tree {tree}{}{cont}",
760 yellow(&format!("adoption report lost ({reason})")),
761 if f.committed {
762 String::new()
763 } else {
764 red(" (NO COMMIT)")
765 }
766 ),
767 None => format!(
768 " fix: {} addressed / {} rejected, tree {tree}{}{cont}",
769 f.addressed.len(),
770 f.rejected.len(),
771 if f.committed {
772 String::new()
773 } else {
774 red(" (NO COMMIT)")
775 }
776 ),
777 }
778 })
779 );
780 for o in &r.e2e {
787 let label = if o.resource_blocked {
788 yellow("blocked")
789 } else if o.ok() {
790 green("pass")
791 } else {
792 red("FAIL")
793 };
794 let _ = writeln!(s, " {label} {}", o.command);
795 if !o.ok() {
796 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
797 }
798 }
799 for rec in &r.reviews {
800 if let Some(vote) = rec.vote {
801 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
802 }
803 for f in &rec.findings {
804 let adopted = r
805 .fix
806 .as_ref()
807 .is_some_and(|fix| fix.addressed.contains(&f.id));
808 let _ = writeln!(
809 s,
810 " {} [{:?}] {}{}",
811 dim(&f.id),
812 f.severity,
813 f.title,
814 if adopted {
815 green(" fixed")
816 } else {
817 String::new()
818 }
819 );
820 }
821 }
822 if let Some(fix) = &r.fix {
823 for rej in &fix.rejected {
824 let _ = writeln!(
825 s,
826 " {} {}: {}",
827 dim(&rej.id),
828 yellow("declined"),
829 rej.why
830 );
831 }
832 }
833 if !r.reconsideration.is_empty() {
837 let _ = writeln!(s, " {}", dim("reconsideration:"));
838 for rv in &r.reconsideration {
839 match rv.vote {
840 Some(v) => {
841 let _ = writeln!(
842 s,
843 " review-{} -> {} {}",
844 rv.reviewer,
845 vote_tag(v),
846 rv.reason
847 );
848 }
849 None => {
850 let _ = writeln!(
851 s,
852 " review-{} -> {}",
853 rv.reviewer,
854 red(&format!(
855 "no revote ({})",
856 rv.failed.as_deref().unwrap_or("unknown")
857 ))
858 );
859 }
860 }
861 }
862 }
863 }
864 if state.handed_off_with_open_findings() {
865 let _ = writeln!(
866 s,
867 "\n {}",
868 yellow(&format!(
869 "handed off with {} finding(s) still open — gate and e2e were green; \
870 see above for what a person should still look at",
871 state.open_findings().len()
872 ))
873 );
874 }
875 }
876
877 if !state.merge_repairs.is_empty() {
878 let _ = writeln!(s, "\n{}", bold("merge-gate repairs"));
879 for r in &state.merge_repairs {
880 let _ = writeln!(
881 s,
882 " question {} head {} {:?}\n {}",
883 r.question,
884 crate::run::short_of(&r.head),
885 r.phase,
886 r.pr
887 );
888 if let Some(why) = &r.failure {
889 let _ = writeln!(s, " held: {why}");
890 }
891 if let Some(round) = r.reviews.last() {
892 for f in round.reviews.iter().flat_map(|rec| &rec.findings) {
893 let _ = writeln!(s, " original [{}] {:?}: {}", f.id, f.severity, f.title);
894 }
895 for (seat, _, vote) in round.final_votes() {
896 let _ = writeln!(s, " original reviewer #{seat}: {:?}", vote);
897 }
898 }
899 }
900 s.push_str(
901 " Repair never approves a merge; updated results require a fresh owner decision.\n",
902 );
903 }
904
905 if !state.operator_fixes.is_empty() {
906 let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
907 for (i, req) in state.operator_fixes.iter().enumerate() {
908 let _ = writeln!(
909 s,
910 " [{}] {} finding(s) at {}{}",
911 i + 1,
912 req.findings.len(),
913 req.requested_at
914 .to_zoned(jiff::tz::TimeZone::system())
915 .strftime("%Y-%m-%d %H:%M:%S"),
916 if req.stale {
917 yellow(" stale head, --allow-stale used")
918 } else {
919 String::new()
920 }
921 );
922 let _ = writeln!(s, " reason: {}", req.reason);
923 for f in &req.findings {
924 let outcome = match &f.outcome {
925 OperatorFixOutcome::Pending => yellow("pending"),
926 OperatorFixOutcome::Addressed => green("addressed"),
927 OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
928 OperatorFixOutcome::Unreported => {
929 red("unreported — no adoption report came back")
930 }
931 };
932 let _ = writeln!(
933 s,
934 " {} [{:?}] {} {outcome}",
935 dim(&f.id),
936 f.severity,
937 f.title
938 );
939 }
940 match &req.follow_up_review_run {
941 Some(id) => {
942 let _ = writeln!(s, " re-verified by run {id}");
943 }
944 None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
945 let _ = writeln!(
946 s,
947 " {}",
948 red("committed, but the follow-up review could not be opened")
949 );
950 }
951 None => {
952 let _ = writeln!(s, " no change committed; nothing to re-verify");
953 }
954 }
955 }
956 }
957
958 if let Some(bs) = &state.base_sync {
959 let _ = writeln!(s, "\n{}", bold("base sync"));
960 let status = if let Some(a) = &bs.already_in {
961 dim(&format!(
962 "already in {} as {} ({} match) - nothing left to land",
963 state.base_branch,
964 a.names(),
965 a.proof.as_str()
966 ))
967 } else if let Some(c) = &bs.conflict {
968 red(&format!("conflict: {}", first_line(c)))
969 } else if bs.behind == 0 {
970 green("in sync")
971 } else {
972 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
973 };
974 let _ = writeln!(
975 s,
976 " {} @ {} {status}{}",
977 state.base_branch,
978 short(&bs.tip),
979 if bs.attempts > 0 {
980 format!(" ({} rebase attempt(s))", bs.attempts)
981 } else {
982 String::new()
983 }
984 );
985 }
986
987 if !state.pre_gate.is_empty() {
988 let _ = writeln!(s, "\n{}", bold("pre_gate"));
989 for o in &state.pre_gate {
990 let _ = writeln!(
991 s,
992 " {} {}",
993 if o.ok() {
994 green("pass")
995 } else {
996 yellow("warn")
997 },
998 o.command
999 );
1000 if !o.ok() {
1001 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
1002 }
1003 }
1004 if let Some(c) = &state.pre_gate_commit {
1005 let _ = writeln!(s, " committed mechanical fixes @ {}", short(c));
1006 }
1007 }
1008
1009 match state.gate_status() {
1013 GateStatus::NotRun => {}
1014 GateStatus::NotConfigured => {
1015 let _ = writeln!(s, "\n{}", bold("gate"));
1016 let _ = writeln!(s, " {} no gate commands configured", dim("not tested"));
1017 }
1018 GateStatus::Passed | GateStatus::Failed => {
1019 let _ = writeln!(s, "\n{}", bold("gate"));
1020 for o in &state.gate {
1021 let _ = writeln!(
1022 s,
1023 " {} {}",
1024 if o.ok() { green("pass") } else { red("FAIL") },
1025 o.command
1026 );
1027 if !o.ok() {
1028 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
1029 }
1030 }
1031 }
1032 }
1033
1034 if let Some(m) = &state.merge {
1035 let _ = writeln!(s, "\n{}", bold("merge"));
1036 if m.mode == MergeMode::None {
1037 let _ = writeln!(
1041 s,
1042 " mode None {}",
1043 cyan("not landed — nothing to do by design")
1044 );
1045 if let Some(w) = state.winner() {
1046 let _ = writeln!(
1047 s,
1048 " branch {} still exists, unmerged into {}",
1049 w.branch, state.base_branch
1050 );
1051 }
1052 let _ = writeln!(
1058 s,
1059 " rebase onto {} before merging by hand{}",
1060 state.base_branch,
1061 if state.config.merge.style == MergeStyle::Squash {
1062 ", and pass an explicit commit message — a squash merge \
1063 otherwise inherits the candidate's placeholder subject"
1064 } else {
1065 ""
1066 }
1067 );
1068 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
1069 } else {
1070 let _ = writeln!(
1071 s,
1072 " mode {:?} {}\n {}",
1073 m.mode,
1074 if m.empty {
1075 yellow("empty candidate — nothing to land")
1076 } else if m.ok && state.status == RunStatus::Merged {
1077 green("merged")
1078 } else if m.ok && m.mode == MergeMode::Pr {
1079 yellow("published; not merged")
1080 } else {
1081 yellow("not merged")
1082 },
1083 m.detail.lines().next().unwrap_or("")
1084 );
1085 }
1086 if let Some(pr) = &state.pr
1087 && !pr.red_at_merge.is_empty()
1088 {
1089 let _ = writeln!(
1090 s,
1091 " {} {}",
1092 yellow("merged with red checks:"),
1093 pr.red_at_merge.join(", ")
1094 );
1095 }
1096 }
1097
1098 if !state.followups.is_empty() {
1099 let _ = writeln!(s, "\n{}", bold("follow-ups"));
1100 for f in &state.followups {
1101 let _ = writeln!(
1102 s,
1103 " {} {}",
1104 crate::queue::short(&f.task),
1105 f.findings.join(", ")
1106 );
1107 }
1108 }
1109
1110 if !state.bump_decision_attempts.is_empty() {
1111 let _ = writeln!(s, "\n{}", bold("release decision attempts"));
1112 for (n, attempt) in state.bump_decision_attempts.iter().enumerate() {
1113 let _ = writeln!(s, " {}. {}: {}", n + 1, attempt.agent, attempt.outcome);
1114 }
1115 }
1116 if let Some(b) = &state.release_bump {
1117 let _ = writeln!(s, "\n{}", bold("release bump"));
1118 if let Some(why) = &b.skipped {
1119 let _ = writeln!(
1120 s,
1121 " {} {why}",
1122 bold(&yellow("SKIPPED (no release was made):"))
1123 );
1124 }
1125 if let Some(v) = &b.version {
1126 let _ = writeln!(s, " version v{v}");
1127 }
1128 if let Some(url) = &b.pr_url {
1129 let _ = writeln!(s, " pr {url}");
1130 }
1131 if b.skipped.is_none() || b.pr_url.is_some() {
1132 let _ = writeln!(
1133 s,
1134 " automerge {}",
1135 if b.automerge_enabled {
1136 green("enabled")
1137 } else if b.merged_directly {
1138 green("not needed (CI was already green; merged directly)")
1139 } else if b.local {
1140 dim(
1141 "not used (release.mode = local: merged on your approval, released by magi serve)",
1142 )
1143 } else if b.problem.is_some() {
1144 bold(&red("FAILED"))
1145 } else {
1146 dim("not enabled")
1147 }
1148 );
1149 }
1150 if let Some(p) = &b.problem {
1151 let _ = writeln!(s, " {}", p.lines().next().unwrap_or(""));
1152 }
1153 if let Some(j) = &b.release {
1154 let state = if j.finished {
1155 "finished"
1156 } else {
1157 j.failed.as_deref().unwrap_or("unfinished")
1158 };
1159 let _ = writeln!(s, " release {} at {}: {state}", j.tag(), j.commit);
1160 for l in &j.log {
1161 let code = l.code.map_or("none".to_owned(), |c| c.to_string());
1162 let _ = write!(s, " {} (exit {code})", l.name);
1163 if let Some(o) = &l.output {
1164 let _ = write!(s, " output {o}");
1165 }
1166 let _ = writeln!(s);
1167 }
1168 }
1169 if let Some(a) = &b.action_required {
1170 let _ = writeln!(s, " {} {a}", bold(&yellow("action required:")));
1171 }
1172 }
1173
1174 if !state.leaks.is_empty() {
1175 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
1176 for l in &state.leaks {
1177 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
1178 }
1179 }
1180
1181 if let Some(w) = state.winner()
1182 && !w.folded
1183 {
1184 let _ = writeln!(
1185 s,
1186 "\n{} {}\n branch {}",
1187 bold("winner worktree"),
1188 w.worktree.display(),
1189 w.branch
1190 );
1191 }
1192 s.push_str(&jobs_section(state));
1193 s
1194}
1195
1196pub fn active_seats(state: &RunState, live: Liveness) -> String {
1212 let mut s = String::new();
1213 if let Some(block) = &state.resource_block {
1214 let _ = writeln!(
1215 s,
1216 "\n{}\n {}. Resume to retry the affected operation.",
1217 bold("waiting on a resource"),
1218 block.description()
1219 );
1220 }
1221 if state.active.is_empty() {
1222 return s;
1223 }
1224 let _ = writeln!(s, "\n{}", bold("running now"));
1225 let now = jiff::Timestamp::now();
1226 match live {
1227 Liveness::Live => {}
1228 Liveness::Dead => {
1229 let _ = writeln!(
1230 s,
1231 " {}",
1232 yellow(
1233 "no live daemon claims this run right now — likely left behind by a killed process"
1234 )
1235 );
1236 }
1237 Liveness::Unknown => {
1238 let overrun = if state.active_all_overrun(now) {
1244 " — every active seat has already run past its own timeout budget"
1245 } else {
1246 ""
1247 };
1248 let _ = writeln!(
1249 s,
1250 " {}",
1251 yellow(&format!(
1252 "whether a process is still driving this run could not be confirmed{overrun}"
1253 ))
1254 );
1255 }
1256 }
1257 for (seat, a) in state.seats_active() {
1258 let retry = if a.attempt > 0 {
1259 format!(" retry {}", a.attempt)
1260 } else {
1261 String::new()
1262 };
1263 if let Some(w) = state.resource_wait.as_ref().filter(|w| w.node == a.node) {
1265 let _ = writeln!(
1266 s,
1267 " {:<12} {:<12}{retry} waiting for the {} ({}), {}s of {}s; agent not started yet",
1268 seat,
1269 a.node,
1270 w.kind,
1271 w.holder,
1272 (now.as_second() - w.since.as_second()).max(0),
1273 w.limit_secs
1274 );
1275 continue;
1276 }
1277 let _ = writeln!(
1278 s,
1279 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
1280 seat,
1281 a.node,
1282 a.elapsed_secs(now),
1283 a.remaining_secs(now),
1284 a.timeout_secs
1285 );
1286 }
1287 for (task, a) in state.tasks_active() {
1288 let retry = if a.attempt > 0 {
1289 format!(" retry {}", a.attempt)
1290 } else {
1291 String::new()
1292 };
1293 let progress = match (a.index, a.total) {
1294 (Some(i), Some(t)) => format!(" ({i}/{t})"),
1295 _ => String::new(),
1296 };
1297 let _ = writeln!(
1298 s,
1299 " {:<12} {:<12}{retry}{progress} {}s elapsed, {}s left of {}s",
1300 task,
1301 a.node,
1302 a.elapsed_secs(now),
1303 a.remaining_secs(now),
1304 a.timeout_secs
1305 );
1306 if let Some(command) = &a.command {
1307 let _ = writeln!(s, " {command}");
1308 }
1309 }
1310 s
1311}
1312
1313fn fmt_secs(secs: i64) -> String {
1315 let secs = secs.max(0);
1316 let h = secs / 3600;
1317 let m = (secs % 3600) / 60;
1318 let s = secs % 60;
1319 if h > 0 {
1320 format!("{h}h{m:02}m")
1321 } else if m > 0 {
1322 format!("{m}m{s:02}s")
1323 } else {
1324 format!("{s}s")
1325 }
1326}
1327
1328fn bar(value: f64, max: f64, width: usize) -> String {
1333 if max <= 0.0 || value <= 0.0 {
1334 return String::new();
1335 }
1336 let cells = ((value / max) * width as f64)
1337 .round()
1338 .clamp(1.0, width as f64) as usize;
1339 "█".repeat(cells)
1340}
1341
1342pub fn repo_summary(repos: &[RepoStats]) -> String {
1348 let mut s = String::new();
1349 let _ = writeln!(s, "{}", bold("repos"));
1350
1351 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1355 for r in repos {
1356 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
1357 }
1358
1359 for r in repos {
1360 let label = if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
1361 match r.repo.parent().and_then(|p| p.file_name()) {
1362 Some(parent) => format!("{} ({})", r.name, parent.to_string_lossy()),
1363 None => r.name.clone(),
1364 }
1365 } else {
1366 r.name.clone()
1367 };
1368 let _ = writeln!(
1369 s,
1370 " {} {} runs {:.0}% completion",
1371 label,
1372 r.stats.totals.runs,
1373 r.stats.totals.completion_rate()
1374 );
1375 }
1376 s
1377}
1378
1379pub fn stats(stats: &Stats) -> String {
1381 let t = &stats.totals;
1382 let mut s = String::new();
1383 let _ = writeln!(s, "{}", bold("runs"));
1384 let _ = writeln!(
1385 s,
1386 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1387 t.runs,
1388 t.merged,
1389 t.ready,
1390 t.blocked,
1391 t.failed,
1392 t.completion_rate()
1393 );
1394 if t.tallied > 0 {
1395 let _ = writeln!(
1396 s,
1397 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1398 {} of those changed a mind, {} converged to unanimous",
1399 t.tallied,
1400 t.split,
1401 t.split_rate(),
1402 t.deliberated,
1403 t.minds_changed,
1404 t.converged
1405 );
1406 }
1407
1408 if !stats.retired_hidden.is_empty() {
1409 let n = stats.retired_hidden.len();
1410 let _ = writeln!(
1411 s,
1412 "{n} retired agent{} hidden: {} (magi stats --all to show)\n",
1413 if n == 1 { "" } else { "s" },
1414 stats.retired_hidden.join(", ")
1415 );
1416 }
1417
1418 let has_competitive = stats.agents.iter().any(|a| a.competitive_entered > 0);
1419 if has_competitive {
1420 let mut comp_agents = stats.agents.clone();
1421 comp_agents.retain(|a| a.competitive_entered > 0);
1422 comp_agents.sort_by(|a, b| {
1423 let a_tier = if a.competitive_entered < 10 { 1 } else { 0 };
1424 let b_tier = if b.competitive_entered < 10 { 1 } else { 0 };
1425 a_tier
1426 .cmp(&b_tier)
1427 .then(
1428 b.competitive_win_rate()
1429 .total_cmp(&a.competitive_win_rate()),
1430 )
1431 .then(b.competitive_entered.cmp(&a.competitive_entered))
1432 .then(a.agent.cmp(&b.agent))
1433 });
1434
1435 let _ = writeln!(
1436 s,
1437 "\n{}",
1438 bold("implementation (competitive runs, 2+ candidates)")
1439 );
1440 let _ = writeln!(
1441 s,
1442 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1443 "agent", "won", "entered", "rate", "empty"
1444 );
1445 for a in &comp_agents {
1446 let low_n_marker = if a.competitive_entered < 10 {
1447 format!(" {}", dim("low n"))
1448 } else {
1449 String::new()
1450 };
1451 let _ = writeln!(
1452 s,
1453 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}{}",
1454 a.agent,
1455 a.competitive_wins,
1456 a.competitive_entered,
1457 a.competitive_win_rate(),
1458 a.empty,
1459 low_n_marker
1460 );
1461 }
1462 }
1463
1464 let has_solo = stats.agents.iter().any(|a| a.solo_entered > 0);
1465 if has_solo {
1466 let mut solo_agents = stats.agents.clone();
1467 solo_agents.retain(|a| a.solo_entered > 0);
1468 solo_agents.sort_by(|a, b| {
1469 let a_tier = if a.solo_entered < 10 { 1 } else { 0 };
1470 let b_tier = if b.solo_entered < 10 { 1 } else { 0 };
1471 a_tier
1472 .cmp(&b_tier)
1473 .then(b.solo_adoption_rate().total_cmp(&a.solo_adoption_rate()))
1474 .then(b.solo_entered.cmp(&a.solo_entered))
1475 .then(a.agent.cmp(&b.agent))
1476 });
1477
1478 let _ = writeln!(
1479 s,
1480 "\n{}",
1481 bold("implementation (solo runs, completion/adoption rate)")
1482 );
1483 let _ = writeln!(
1484 s,
1485 " {:<14}{:>8}{:>8}{:>8}",
1486 "agent", "adopted", "entered", "rate"
1487 );
1488 for a in &solo_agents {
1489 let low_n_marker = if a.solo_entered < 10 {
1490 format!(" {}", dim("low n"))
1491 } else {
1492 String::new()
1493 };
1494 let _ = writeln!(
1495 s,
1496 " {:<14}{:>8}{:>8}{:>7.0}%{}",
1497 a.agent,
1498 a.solo_adopted,
1499 a.solo_entered,
1500 a.solo_adoption_rate(),
1501 low_n_marker
1502 );
1503 }
1504 }
1505
1506 if !stats.reviewers.is_empty() {
1507 let _ = writeln!(s, "\n{}", bold("review"));
1508 let _ = writeln!(
1509 s,
1510 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1511 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1512 );
1513 for r in &stats.reviewers {
1514 let _ = writeln!(
1515 s,
1516 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1517 r.agent,
1518 r.rounds,
1519 r.submitted,
1520 r.adopted_per_round(),
1521 r.precision(),
1522 r.unique_rate(),
1523 r.timeout_rate()
1524 );
1525 }
1526 }
1527
1528 if !stats.advisors.is_empty() {
1529 let _ = writeln!(
1530 s,
1531 "\n{} {}",
1532 bold("design deliberation"),
1533 dim(
1534 "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1535 )
1536 );
1537 let _ = writeln!(
1538 s,
1539 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1540 "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1541 );
1542 for a in &stats.advisors {
1543 let _ = writeln!(
1544 s,
1545 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1546 a.agent,
1547 a.seated,
1548 a.proposed,
1549 a.strong,
1550 a.faint,
1551 a.absent,
1552 a.reflection_rate()
1553 );
1554 }
1555 }
1556
1557 if stats.release_bumps.merged > 0 {
1558 let b = &stats.release_bumps;
1559 let _ = writeln!(s, "\n{}", bold("release bumps"));
1560 let _ = writeln!(
1561 s,
1562 " {} of {} merged run(s) recorded a bump ({:.0}%)",
1563 b.recorded,
1564 b.merged,
1565 b.coverage_rate()
1566 );
1567 let _ = writeln!(
1568 s,
1569 " {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1570 b.pr_opened,
1571 b.automerge_enabled,
1572 b.automerge_rate(),
1573 b.merged_directly
1574 );
1575 let _ = writeln!(
1576 s,
1577 " {} clean, {} needed a human ({:.0}%)",
1578 b.clean(),
1579 b.needs_attention,
1580 b.attention_rate()
1581 );
1582 }
1583
1584 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1585 let _ = writeln!(s, "\n{}", bold("verification"));
1586 let _ = writeln!(
1587 s,
1588 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1589 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1590 stats.e2e.rounds,
1591 stats.e2e.failures,
1592 stats.e2e.sole_detections,
1593 stats.e2e.sole_rate(),
1594 stats.e2e.deferred
1595 );
1596 }
1597
1598 if !stats.nodes.is_empty() {
1599 let _ = writeln!(
1600 s,
1601 "\n{}",
1602 bold("node durations (first to last event, per run)")
1603 );
1604 let max_mean = stats
1605 .nodes
1606 .iter()
1607 .map(|n| n.mean_secs())
1608 .fold(0.0_f64, f64::max);
1609 for n in &stats.nodes {
1610 let unmeasured = if n.single > 0 {
1611 format!(" {}", dim(&format!("{} unmeasured", n.single)))
1612 } else {
1613 String::new()
1614 };
1615 let _ = writeln!(
1616 s,
1617 " {:<14}{:>6} runs{:>8} mean{:>8} max {}{unmeasured}",
1618 n.node,
1619 n.runs,
1620 fmt_secs(n.mean_secs().round() as i64),
1621 fmt_secs(n.max_secs),
1622 bar(n.mean_secs(), max_mean, 20)
1623 );
1624 }
1625 }
1626 s
1627}
1628
1629#[cfg(test)]
1630mod tests {
1631 use super::*;
1632 use crate::config::Config;
1633 use crate::run::{
1634 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1635 Tally,
1636 };
1637 use std::collections::BTreeMap;
1638 use std::path::PathBuf;
1639 use std::sync::{Mutex, MutexGuard};
1640
1641 static SERIAL: Mutex<()> = Mutex::new(());
1643
1644 fn plain() -> MutexGuard<'static, ()> {
1645 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1646 set_color(false);
1647 guard
1648 }
1649
1650 fn state() -> RunState {
1651 crate::run::pin_test_home();
1656 let mut s = RunState::new(
1657 PathBuf::from("/repo"),
1658 "main".to_owned(),
1659 "abcdef1234".to_owned(),
1660 "add retries to the uploader".to_owned(),
1661 Config::default(),
1662 );
1663 s.candidates = vec![Candidate {
1664 index: 0,
1665 label: 'A',
1666 agent: "opus".to_owned(),
1667 branch: "magi/x/A".to_owned(),
1668 worktree: PathBuf::from("/wt/A"),
1669 summary: String::new(),
1670 stat: String::new(),
1671 files: 3,
1672 commits: 2,
1673 empty: false,
1674 failed: None,
1675 verified_noop: None,
1676 duration_ms: 42_000,
1677 folded: false,
1678 }];
1679 s.tally = Some(Tally {
1680 first_choice: BTreeMap::from([('A', 3)]),
1681 borda: BTreeMap::new(),
1682 winner: 'A',
1683 rankings: 3,
1684 unanimous_initial: true,
1685 deliberated: false,
1686 changed_votes: 0,
1687 unanimous_final: true,
1688 tie_break: None,
1689 judges: 3,
1690 present: 3,
1691 quorum: 2,
1692 met_quorum: true,
1693 uncontested: None,
1694 });
1695 s
1696 }
1697
1698 #[test]
1699 fn run_report_names_the_winner_and_its_author() {
1700 let _guard = plain();
1701 let text = run(&state());
1702 assert!(text.contains("<- winner"), "{text}");
1703 assert!(text.contains("opus"));
1704 assert!(text.contains("3 files, 2 commits"));
1705 assert!(text.contains("winner A"));
1706 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1707 }
1708
1709 #[test]
1710 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1711 let _guard = plain();
1717 let mut s = state();
1718 s.candidates[0].summary =
1719 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1720 .to_owned();
1721 let text = run(&s);
1722 assert!(
1723 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1724 "{text}"
1725 );
1726 }
1727
1728 #[test]
1729 fn a_verified_noop_run_does_not_read_as_a_failure() {
1730 let _guard = plain();
1734 let mut s = state();
1735 s.status = RunStatus::VerifiedNoop;
1736 s.tally = None;
1737 s.candidates = vec![Candidate {
1738 index: 0,
1739 label: 'A',
1740 agent: "opus".to_owned(),
1741 branch: "magi/x/A".to_owned(),
1742 worktree: PathBuf::from("/wt/A"),
1743 summary: String::new(),
1744 stat: String::new(),
1745 files: 0,
1746 commits: 0,
1747 empty: true,
1748 failed: None,
1749 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1750 duration_ms: 9_000,
1751 folded: false,
1752 }];
1753 let text = run(&s);
1754 assert!(
1755 text.contains("agent-verified no-op"),
1756 "the status and the candidate flag must both say so: {text}"
1757 );
1758 assert!(
1759 text.contains("already fixed by b32cfc4"),
1760 "the evidence itself must be readable, not just the verdict: {text}"
1761 );
1762 assert!(
1763 !text.to_lowercase().contains("failed"),
1764 "a verified no-op must never read as the failure it is not: {text}"
1765 );
1766 }
1767
1768 #[test]
1769 fn colour_is_emitted_only_when_enabled() {
1770 let _guard = plain();
1771 set_color(true);
1772 let coloured = run(&state());
1773 set_color(false);
1774 let plain = run(&state());
1775 assert!(coloured.contains('\x1b'));
1776 assert!(!plain.contains('\x1b'));
1777 assert!(coloured.len() > plain.len());
1778 }
1779
1780 #[test]
1781 fn list_line_is_single_line() {
1782 let _guard = plain();
1783 let l = line(&state());
1784 assert_eq!(l.lines().count(), 1);
1785 assert!(l.contains("add retries"));
1786 assert!(l.contains("win A (opus)"));
1787 }
1788
1789 #[test]
1790 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1791 let _guard = plain();
1792 let mut s = state();
1793 s.tally = Some(Tally {
1794 first_choice: BTreeMap::from([('A', 0)]),
1795 borda: BTreeMap::new(),
1796 winner: 'A',
1797 rankings: 0,
1798 unanimous_initial: false,
1799 deliberated: false,
1800 changed_votes: 0,
1801 unanimous_final: false,
1802 tie_break: None,
1803 judges: 0,
1804 present: 0,
1805 quorum: 0,
1806 met_quorum: true,
1807 uncontested: Some(
1808 "only candidate A produced a usable change; no panel was asked".to_owned(),
1809 ),
1810 });
1811 let text = run(&s);
1812 assert!(
1813 !text.contains("0/3"),
1814 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1815 );
1816 assert!(!text.contains("no usable ranking"), "{text}");
1817 assert!(!text.contains("still split"), "{text}");
1818 assert!(!text.contains("BELOW QUORUM"), "{text}");
1819 assert!(
1820 text.contains("not needed"),
1821 "the report must say judging was skipped, not silent: {text}"
1822 );
1823 assert!(text.contains("winner A"));
1824 }
1825
1826 #[test]
1827 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1828 let _guard = plain();
1829 let mut s = state();
1830 s.tally = Some(Tally {
1831 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1832 borda: BTreeMap::new(),
1833 winner: 'A',
1834 rankings: 1,
1835 unanimous_initial: false,
1836 deliberated: false,
1837 changed_votes: 0,
1838 unanimous_final: false,
1839 tie_break: None,
1840 judges: 3,
1841 present: 1,
1842 quorum: 2,
1843 met_quorum: false,
1844 uncontested: None,
1845 });
1846 let text = run(&s);
1847 assert!(text.contains("1/3"), "{text}");
1848 assert!(
1849 text.contains("BELOW QUORUM"),
1850 "a real collapse must still be flagged: {text}"
1851 );
1852 assert!(
1853 !text.contains("not needed"),
1854 "a collapsed panel must not be described as one that was never asked: {text}"
1855 );
1856 }
1857
1858 #[test]
1859 fn a_mode_none_merge_does_not_read_as_landed() {
1860 let _guard = plain();
1861 let mut s = state();
1862 s.merge = Some(MergeOutcome {
1863 mode: crate::config::MergeMode::None,
1864 ok: true,
1865 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1866 empty: false,
1867 });
1868 let text = run(&s);
1869 assert!(
1870 !text.contains(" ok"),
1871 "mode none must not be shown as a landed merge: {text}"
1872 );
1873 assert!(text.contains("not landed"), "{text}");
1874 assert!(
1875 text.contains("branch magi/x/A"),
1876 "the report must say what's left behind: {text}"
1877 );
1878 assert!(
1879 text.contains("rebase"),
1880 "the report must point at the hand-landing steps: {text}"
1881 );
1882 assert!(
1883 !text.contains("placeholder subject"),
1884 "the default merge style is `merge`, which never inherits a \
1885 placeholder subject, so the squash caveat must not appear: {text}"
1886 );
1887 }
1888
1889 #[test]
1890 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1891 let _guard = plain();
1892 let mut s = state();
1893 s.config.merge.style = MergeStyle::Squash;
1894 s.merge = Some(MergeOutcome {
1895 mode: crate::config::MergeMode::None,
1896 ok: true,
1897 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1898 retries\""
1899 .to_owned(),
1900 empty: false,
1901 });
1902 let text = run(&s);
1903 assert!(
1904 text.contains("placeholder subject"),
1905 "a squash-style manual merge must warn about the missing message: {text}"
1906 );
1907 assert!(text.contains("--squash"), "{text}");
1908 }
1909
1910 #[test]
1911 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1912 let _guard = plain();
1913 let mut s = state();
1914 s.status = RunStatus::Ready;
1915 s.merge = Some(MergeOutcome {
1916 mode: crate::config::MergeMode::None,
1917 ok: true,
1918 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1919 empty: false,
1920 });
1921
1922 let list = line(&s);
1923 assert!(
1924 !list.contains(" ready "),
1925 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1926 );
1927 assert!(list.contains("no-op by design"), "{list}");
1928
1929 let full = run(&s);
1930 assert!(
1931 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1932 "the header line of `magi show` must not say plain ready either: {full}"
1933 );
1934 assert!(full.contains("no-op by design"), "{full}");
1935 }
1936
1937 #[test]
1938 fn an_ordinary_ready_run_still_reads_as_ready() {
1939 let _guard = plain();
1940 let mut s = state();
1941 s.status = RunStatus::Ready;
1942 s.merge = Some(MergeOutcome {
1946 mode: crate::config::MergeMode::Pr,
1947 ok: false,
1948 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1949 empty: false,
1950 });
1951
1952 let list = line(&s);
1953 assert!(list.contains("ready"), "{list}");
1954 assert!(!list.contains("no-op by design"), "{list}");
1955 }
1956
1957 #[test]
1958 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1959 let _guard = plain();
1960 let mut s = state();
1961 s.tally = Some(Tally {
1962 first_choice: BTreeMap::from([('A', 0)]),
1963 borda: BTreeMap::new(),
1964 winner: 'A',
1965 rankings: 0,
1966 unanimous_initial: false,
1967 deliberated: false,
1968 changed_votes: 0,
1969 unanimous_final: false,
1970 tie_break: None,
1971 judges: 0,
1972 present: 0,
1973 quorum: 0,
1974 met_quorum: true,
1975 uncontested: Some("only candidate A produced a usable change".to_owned()),
1976 });
1977 let l = line(&s);
1978 assert!(
1979 !l.contains("judges") && !l.contains("quorum"),
1980 "an uncontested run must not carry the same badge a short panel gets: {l}"
1981 );
1982 }
1983
1984 #[test]
1985 fn long_instructions_are_elided() {
1986 let _guard = plain();
1987 let mut s = state();
1988 s.instruction = "x".repeat(200);
1989 assert!(line(&s).contains('…'));
1990 }
1991
1992 #[test]
1993 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1994 let _guard = plain();
1995 let mut lost = state();
1996 lost.reviews = vec![ReviewRound {
1997 round: 1,
1998 head: "abc1234".to_owned(),
1999 verified_head: None,
2000 verified_at: None,
2001 reviews: Vec::new(),
2002 e2e: Vec::new(),
2003 verify_retried: false,
2004 e2e_deferred: false,
2005 e2e_defer_reason: None,
2006 fix: Some(FixRecord {
2007 agent: "opus".to_owned(),
2008 addressed: Vec::new(),
2009 rejected: Vec::new(),
2010 notes: String::new(),
2011 committed: true,
2012 failed: Some("timed out".to_owned()),
2013 duration_ms: 0,
2014 continuation: None,
2015 }),
2016 blocking: 3,
2017 answered: 0,
2018 expected: 0,
2019 clean: false,
2020 progressed: false,
2021 vote_split: false,
2022 reconsideration: Vec::new(),
2023 verdict: None,
2024 }];
2025 let text = run(&lost);
2026 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2027 assert!(
2028 !text.contains("0 addressed"),
2029 "a lost report must never read as `0 addressed`: {text}"
2030 );
2031
2032 let mut rejected_all = state();
2033 rejected_all.reviews = vec![ReviewRound {
2034 round: 1,
2035 head: "abc1234".to_owned(),
2036 verified_head: None,
2037 verified_at: None,
2038 reviews: Vec::new(),
2039 e2e: Vec::new(),
2040 verify_retried: false,
2041 e2e_deferred: false,
2042 e2e_defer_reason: None,
2043 fix: Some(FixRecord {
2044 agent: "opus".to_owned(),
2045 addressed: Vec::new(),
2046 rejected: Vec::new(),
2047 notes: String::new(),
2048 committed: true,
2049 failed: None,
2050 duration_ms: 0,
2051 continuation: None,
2052 }),
2053 blocking: 3,
2054 answered: 0,
2055 expected: 0,
2056 clean: false,
2057 progressed: false,
2058 vote_split: false,
2059 reconsideration: Vec::new(),
2060 verdict: None,
2061 }];
2062 let text2 = run(&rejected_all);
2063 assert!(
2064 text2.contains("0 addressed / 0 rejected"),
2065 "a round the fixer actually reported on keeps the count: {text2}"
2066 );
2067 }
2068
2069 #[test]
2070 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
2071 use crate::run::ReviewRevoteRecord;
2072 use crate::verdict::ReviewVote;
2073
2074 let _guard = plain();
2075 let mut s = state();
2076 s.reviews = vec![ReviewRound {
2077 round: 1,
2078 head: "abc1234".to_owned(),
2079 verified_head: None,
2080 verified_at: None,
2081 reviews: vec![
2082 ReviewRecord {
2083 attempts: 0,
2084 reviewer: 1,
2085 agent: "alpha".to_owned(),
2086 summary: String::new(),
2087 findings: Vec::new(),
2088 vote: Some(ReviewVote::Approve),
2089 failed: None,
2090 duration_ms: 0,
2091 },
2092 ReviewRecord {
2093 attempts: 0,
2094 reviewer: 2,
2095 agent: "beta".to_owned(),
2096 summary: String::new(),
2097 findings: Vec::new(),
2098 vote: Some(ReviewVote::Reject),
2099 failed: None,
2100 duration_ms: 0,
2101 },
2102 ],
2103 e2e: Vec::new(),
2104 verify_retried: false,
2105 e2e_deferred: false,
2106 e2e_defer_reason: None,
2107 fix: None,
2108 blocking: 0,
2109 answered: 2,
2110 expected: 2,
2111 clean: false,
2112 progressed: false,
2113 vote_split: true,
2114 reconsideration: vec![ReviewRevoteRecord {
2115 reviewer: 2,
2116 agent: "beta".to_owned(),
2117 vote: Some(ReviewVote::ApproveWithFindings),
2118 reason: "the other seat's read holds up".to_owned(),
2119 failed: None,
2120 }],
2121 verdict: Some(ReviewVote::ApproveWithFindings),
2122 }];
2123 let text = run(&s);
2124 assert!(text.contains("review-1 vote"), "{text}");
2125 assert!(text.contains("review-2 vote"), "{text}");
2126 assert!(text.contains("panel split"), "{text}");
2127 assert!(text.contains("reconsideration"), "{text}");
2128 assert!(text.contains("the other seat's read holds up"), "{text}");
2129 }
2130
2131 #[test]
2132 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
2133 let _guard = plain();
2139 let mut s = state();
2140 s.reviews = vec![ReviewRound {
2141 round: 1,
2142 head: "abc1234".to_owned(),
2143 verified_head: None,
2144 verified_at: None,
2145 reviews: vec![
2146 ReviewRecord {
2147 attempts: 0,
2148 reviewer: 1,
2149 agent: "alpha".to_owned(),
2150 summary: String::new(),
2151 findings: Vec::new(),
2152 vote: None,
2153 failed: None,
2154 duration_ms: 0,
2155 },
2156 ReviewRecord {
2157 attempts: 0,
2158 reviewer: 2,
2159 agent: "beta".to_owned(),
2160 summary: String::new(),
2161 findings: Vec::new(),
2162 vote: None,
2163 failed: Some("agent timed out".to_owned()),
2164 duration_ms: 0,
2165 },
2166 ],
2167 e2e: Vec::new(),
2168 verify_retried: false,
2169 e2e_deferred: false,
2170 e2e_defer_reason: None,
2171 fix: Some(FixRecord {
2172 agent: "opus".to_owned(),
2173 addressed: Vec::new(),
2174 rejected: Vec::new(),
2175 notes: String::new(),
2176 committed: true,
2177 failed: Some("timed out".to_owned()),
2178 duration_ms: 0,
2179 continuation: None,
2180 }),
2181 blocking: 0,
2182 answered: 1,
2183 expected: 2,
2184 clean: false,
2185 progressed: true,
2186 vote_split: false,
2187 reconsideration: Vec::new(),
2188 verdict: None,
2189 }];
2190 let text = run(&s);
2191 assert!(text.contains("incomplete"), "{text}");
2192 assert!(text.contains("1/2 reviewers answered"), "{text}");
2193 assert!(text.contains("review-2: agent timed out"), "{text}");
2194 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2195 assert!(
2196 !text.contains("clean"),
2197 "a round missing half its panel must never render as clean: {text}"
2198 );
2199 }
2200
2201 #[test]
2202 fn a_build_failure_is_not_reported_as_a_test_failure() {
2203 let _guard = plain();
2204 let mut s = state();
2205 s.reviews = vec![ReviewRound {
2206 round: 1,
2207 head: "abc1234".to_owned(),
2208 verified_head: None,
2209 verified_at: None,
2210 reviews: Vec::new(),
2211 e2e: vec![CommandOutcome {
2212 command: "cargo test".to_owned(),
2213 code: Some(1),
2214 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2215 duration_ms: 100,
2216 resource_blocked: false,
2217 }],
2218 verify_retried: true,
2219 e2e_deferred: false,
2220 e2e_defer_reason: None,
2221 fix: None,
2222 blocking: 0,
2223 answered: 0,
2224 expected: 0,
2225 clean: false,
2226 progressed: false,
2227 vote_split: false,
2228 reconsideration: Vec::new(),
2229 verdict: None,
2230 }];
2231 let text = run(&s);
2232 assert!(text.contains("could not run"), "{text}");
2233 assert!(text.contains("retried once"), "{text}");
2234 assert!(!text.contains("e2e RED"), "{text}");
2235 }
2236
2237 #[test]
2238 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2239 let _guard = plain();
2240 let mut s = state();
2241 s.reviews = vec![ReviewRound {
2242 round: 1,
2243 head: "abc1234".to_owned(),
2244 verified_head: None,
2245 verified_at: None,
2246 reviews: Vec::new(),
2247 e2e: vec![CommandOutcome {
2248 command: "(waiting for the shared build cache)".to_owned(),
2249 code: None,
2250 output_tail: "held by run x node e2e seat e2e".to_owned(),
2251 duration_ms: 100,
2252 resource_blocked: true,
2253 }],
2254 verify_retried: false,
2255 e2e_deferred: false,
2256 e2e_defer_reason: None,
2257 fix: None,
2258 blocking: 0,
2259 answered: 0,
2260 expected: 0,
2261 clean: false,
2262 progressed: false,
2263 vote_split: false,
2264 reconsideration: Vec::new(),
2265 verdict: None,
2266 }];
2267 let text = run(&s);
2268 assert!(text.contains("resource unavailable"), "{text}");
2269 assert!(!text.contains("e2e RED"), "{text}");
2270 assert!(!text.contains("build/link failure"), "{text}");
2271 }
2272
2273 #[test]
2274 fn a_round_with_more_than_one_e2e_command_names_each_one() {
2275 let _guard = plain();
2280 let mut s = state();
2281 s.reviews = vec![ReviewRound {
2282 round: 1,
2283 head: "abc1234".to_owned(),
2284 verified_head: Some("abc1234".to_owned()),
2285 verified_at: Some(jiff::Timestamp::now()),
2286 reviews: Vec::new(),
2287 e2e: vec![
2288 CommandOutcome {
2289 command: "cargo test --locked --all-targets".to_owned(),
2290 code: Some(0),
2291 output_tail: String::new(),
2292 duration_ms: 0,
2293 resource_blocked: false,
2294 },
2295 CommandOutcome {
2296 command: "cargo make check".to_owned(),
2297 code: Some(1),
2298 output_tail: "clippy: unused import".to_owned(),
2299 duration_ms: 0,
2300 resource_blocked: false,
2301 },
2302 ],
2303 verify_retried: false,
2304 e2e_deferred: false,
2305 e2e_defer_reason: None,
2306 fix: None,
2307 blocking: 0,
2308 answered: 0,
2309 expected: 0,
2310 clean: false,
2311 progressed: false,
2312 vote_split: false,
2313 reconsideration: Vec::new(),
2314 verdict: None,
2315 }];
2316 let text = run(&s);
2317 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2318 assert!(text.contains("cargo make check"), "{text}");
2319 assert!(text.contains("clippy: unused import"), "{text}");
2320 }
2321
2322 #[test]
2323 fn a_declined_finding_shows_its_reason() {
2324 use crate::verdict::{Finding, Rejection, Severity};
2325
2326 let _guard = plain();
2327 let mut s = state();
2328 s.status = RunStatus::Ready;
2329 s.reviews = vec![ReviewRound {
2330 round: 1,
2331 head: "deadbee".to_owned(),
2332 verified_head: None,
2333 verified_at: None,
2334 reviews: vec![ReviewRecord {
2335 attempts: 0,
2336 reviewer: 1,
2337 agent: "alpha".to_owned(),
2338 summary: String::new(),
2339 findings: vec![Finding {
2340 id: "R1-1-1".to_owned(),
2341 severity: Severity::Major,
2342 file: None,
2343 line: None,
2344 title: "still open".to_owned(),
2345 detail: String::new(),
2346 }],
2347 vote: None,
2348 failed: None,
2349 duration_ms: 0,
2350 }],
2351 e2e: vec![CommandOutcome {
2352 command: "cargo test".to_owned(),
2353 code: Some(0),
2354 output_tail: String::new(),
2355 duration_ms: 0,
2356 resource_blocked: false,
2357 }],
2358 verify_retried: false,
2359 e2e_deferred: false,
2360 e2e_defer_reason: None,
2361 fix: Some(FixRecord {
2362 agent: "alpha".to_owned(),
2363 addressed: Vec::new(),
2364 rejected: vec![Rejection {
2365 id: "R1-1-2".to_owned(),
2366 why: "cannot be triggered from any caller".to_owned(),
2367 }],
2368 notes: String::new(),
2369 committed: true,
2370 failed: None,
2371 duration_ms: 0,
2372 continuation: None,
2373 }),
2374 blocking: 1,
2375 answered: 1,
2376 expected: 1,
2377 clean: false,
2378 progressed: true,
2379 vote_split: false,
2380 reconsideration: Vec::new(),
2381 verdict: None,
2382 }];
2383
2384 let text = run(&s);
2385 assert!(text.contains("R1-1-2"), "{text}");
2386 assert!(text.contains("cannot be triggered"), "{text}");
2387 assert!(text.contains("still open"), "{text}");
2388 assert!(
2389 text.contains("handed off"),
2390 "a mergeable run with an open round must say so: {text}"
2391 );
2392 }
2393
2394 #[test]
2395 fn a_failing_gate_command_shows_its_output() {
2396 let _guard = plain();
2397 let mut s = state();
2398 s.status = RunStatus::Blocked;
2399 s.gate = vec![CommandOutcome {
2400 command: "cargo make check".to_owned(),
2401 code: Some(101),
2402 output_tail: "error[E0308]: mismatched types".to_owned(),
2403 duration_ms: 0,
2404 resource_blocked: false,
2405 }];
2406 s.gate_ran = true;
2407
2408 let text = run(&s);
2409 assert!(text.contains("mismatched types"), "{text}");
2410 }
2411
2412 #[test]
2413 fn a_gate_with_no_commands_is_reported_as_not_tested() {
2414 let _guard = plain();
2415 let mut s = state();
2416 s.status = RunStatus::Ready;
2417 s.gate_ran = true;
2418 assert!(s.gate.is_empty());
2419
2420 let text = run(&s);
2421 assert!(
2422 text.contains("gate")
2423 && text.contains("no gate commands configured")
2424 && text.contains("not tested"),
2425 "an unconfigured gate must be explicit: {text}"
2426 );
2427 }
2428
2429 #[test]
2430 fn a_gate_that_has_not_run_yet_shows_nothing() {
2431 let _guard = plain();
2432 let s = state();
2433 assert!(!s.gate_ran);
2434 assert!(s.gate.is_empty());
2435
2436 let text = run(&s);
2437 assert!(
2438 !text.contains("no gate commands configured"),
2439 "an unattempted gate must not be shown as a pass: {text}"
2440 );
2441 }
2442
2443 #[test]
2444 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2445 let _guard = plain();
2446 let mut s = state();
2447 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2448 let text = active_seats(&s, Liveness::Live);
2449 assert!(text.contains("running now"));
2450 assert!(text.contains("judge-2"));
2451 assert!(text.contains("judge"));
2452 assert!(!text.contains("no live daemon"), "{text}");
2453 assert!(!text.contains("could not be confirmed"), "{text}");
2454 }
2455
2456 #[test]
2457 fn active_seats_flags_a_leftover_from_a_dead_process() {
2458 let _guard = plain();
2459 let mut s = state();
2460 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2461 let text = active_seats(&s, Liveness::Dead);
2462 assert!(
2463 text.contains("no live daemon"),
2464 "a stale entry must not read as running: {text}"
2465 );
2466 }
2467
2468 #[test]
2469 fn superseded_notice_names_the_run_that_replaced_it() {
2470 let _guard = plain();
2471 let text = superseded_notice(Some("bbbb"));
2472 assert!(text.contains("SUPERSEDED"), "{text}");
2473 assert!(text.contains("bbbb"), "{text}");
2474 }
2475
2476 #[test]
2477 fn superseded_notice_is_silent_for_the_latest_attempt() {
2478 let _guard = plain();
2479 assert_eq!(superseded_notice(None), "");
2480 }
2481
2482 #[test]
2483 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2484 let _guard = plain();
2485 let mut s = state();
2486 s.status = RunStatus::Reviewing;
2487 let text = line_with_liveness(&s, Liveness::Dead);
2488 assert!(text.contains("STALE"), "{text}");
2489 assert!(text.contains("resume required"), "{text}");
2490 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2491 }
2492
2493 #[test]
2498 fn active_seats_reports_uncertainty_without_claiming_death() {
2499 let _guard = plain();
2500 let mut s = state();
2501 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2502 let text = active_seats(&s, Liveness::Unknown);
2503 assert!(
2504 text.contains("could not be confirmed"),
2505 "an unproven state must read as uncertain, not dead: {text}"
2506 );
2507 assert!(!text.contains("no live daemon"), "{text}");
2508 }
2509
2510 #[test]
2511 fn resource_block_reports_saved_evidence_in_text_and_web() {
2512 use crate::run::{ResourceBlock, ResourceBlockCause};
2513 let legacy = serde_json::json!({
2514 "node": "fix", "kind": "shared build cache",
2515 "detail": "the run is parking", "at": "2026-10-24T09:01:51Z"
2516 });
2517 let block: ResourceBlock = serde_json::from_value(legacy).unwrap();
2518 assert_eq!(block.cause, ResourceBlockCause::Unrecorded);
2519 for (cause, detail, expected) in [
2520 (
2521 ResourceBlockCause::Unrecorded,
2522 "the run is parking",
2523 "cancelled for parking",
2524 ),
2525 (ResourceBlockCause::Unrecorded, "busy", "cause unrecorded"),
2526 (
2527 ResourceBlockCause::Other,
2528 "metadata write denied",
2529 "operation blocked",
2530 ),
2531 (
2532 ResourceBlockCause::Timeout {
2533 waited_secs: 12,
2534 limit_secs: 10,
2535 },
2536 "busy",
2537 "timed out after 12s (limit 10s)",
2538 ),
2539 (
2540 ResourceBlockCause::Cancelled {
2541 park: Some(crate::run::ParkCause::Operator),
2542 },
2543 "cancelled",
2544 "cancelled for parking",
2545 ),
2546 ] {
2547 let mut state = state();
2548 state.resource_block = Some(ResourceBlock {
2549 cause: cause.clone(),
2550 detail: detail.into(),
2551 ..block.clone()
2552 });
2553 let text = active_seats(&state, Liveness::Dead);
2554 assert!(text.contains(expected), "{text}");
2555 assert!(
2556 !text.contains("no agent was started"),
2557 "earlier nodes may have run"
2558 );
2559 assert_eq!(
2560 crate::report_view::build_with(&state, Liveness::Dead, None).active_seats,
2561 text
2562 );
2563 let saved = serde_json::to_value(&state.resource_block).unwrap();
2564 let reloaded: ResourceBlock = serde_json::from_value(saved).unwrap();
2565 assert_eq!(reloaded.cause, cause);
2566 }
2567 }
2568
2569 #[test]
2570 fn active_seats_is_empty_when_nothing_is_running() {
2571 let _guard = plain();
2572 assert_eq!(active_seats(&state(), Liveness::Live), "");
2573 }
2574
2575 #[test]
2581 fn active_seats_shows_a_running_verify_task_and_its_command() {
2582 let _guard = plain();
2583 let mut s = state();
2584 s.task_command(
2585 "e2e",
2586 "verify",
2587 0,
2588 "cargo test",
2589 2,
2590 3,
2591 std::time::Duration::from_secs(600),
2592 );
2593 let text = active_seats(&s, Liveness::Live);
2594 assert!(text.contains("e2e"), "{text}");
2595 assert!(text.contains("(2/3)"), "{text}");
2596 assert!(text.contains("cargo test"), "{text}");
2597 }
2598
2599 #[test]
2600 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2601 let _guard = plain();
2602 assert!(!run(&state()).contains("background jobs"));
2605 }
2606
2607 #[test]
2608 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2609 let _guard = plain();
2610 let mut s = state();
2611 s.config.agents.push(crate::config::AgentSpec {
2612 id: "codex-one".to_owned(),
2613 kind: crate::config::AgentKind::Codex,
2614 model: None,
2615 command: vec!["codex".to_owned()],
2616 extra_args: Vec::new(),
2617 env: BTreeMap::new(),
2618 prompt_delivery: None,
2619 max_turn_secs: None,
2620 });
2621 let text = run(&s);
2622 assert!(
2623 text.contains("background jobs"),
2624 "a run that could report this must not read the same as one that never could: \
2625 {text}"
2626 );
2627 assert!(text.contains("no completed command evidence yet"));
2628 }
2629
2630 #[test]
2631 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2632 let _guard = plain();
2633 let mut s = state();
2634 s.jobs = vec![
2635 crate::run::JobRecord {
2636 node: "implement".to_owned(),
2637 round: None,
2638 seat: "impl-A".to_owned(),
2639 id: "item49".to_owned(),
2640 description: "cargo test --test graph_cached_gate".to_owned(),
2641 checked_at: jiff::Timestamp::now(),
2642 status: crate::run::JobStatus::Completed,
2643 exit_code: Some(0),
2644 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2645 source: "codex".to_owned(),
2646 },
2647 crate::run::JobRecord {
2648 node: "fix".to_owned(),
2649 round: None,
2650 seat: "impl-A".to_owned(),
2651 id: "item52".to_owned(),
2652 description: "cargo test --test graph_split".to_owned(),
2653 checked_at: jiff::Timestamp::now(),
2654 status: crate::run::JobStatus::Failed,
2655 exit_code: Some(101),
2656 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2657 source: "codex".to_owned(),
2658 },
2659 crate::run::JobRecord {
2660 node: "fix".to_owned(),
2661 round: None,
2662 seat: "impl-A".to_owned(),
2663 id: "item60".to_owned(),
2664 description: "cargo build".to_owned(),
2665 checked_at: jiff::Timestamp::now(),
2666 status: crate::run::JobStatus::Unknown,
2667 exit_code: None,
2668 result_summary: String::new(),
2669 source: "codex".to_owned(),
2670 },
2671 ];
2672 let text = run(&s);
2673 assert!(text.contains("background jobs"));
2674 assert!(text.contains("item49"));
2675 assert!(text.contains("item52"));
2676 assert!(text.contains("item60"));
2677 assert!(text.contains("completed"));
2681 assert!(text.contains("failed"));
2682 assert!(text.contains("unknown"));
2683 assert!(text.contains("adapter coverage"));
2685 }
2686
2687 #[test]
2688 fn a_jobs_own_round_is_shown_when_known() {
2689 let _guard = plain();
2690 let mut s = state();
2691 s.jobs = vec![crate::run::JobRecord {
2692 node: "review".to_owned(),
2693 round: Some(2),
2694 seat: "review-1".to_owned(),
2695 id: "item9".to_owned(),
2696 description: "cargo test --test graph_cached_gate".to_owned(),
2697 checked_at: jiff::Timestamp::now(),
2698 status: crate::run::JobStatus::Completed,
2699 exit_code: Some(0),
2700 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2701 source: "codex".to_owned(),
2702 }];
2703 let text = run(&s);
2704 assert!(
2705 text.contains("round 2"),
2706 "the round this seat's own command ran in must be visible, distinct from magi's \
2707 own recorded verify: {text}"
2708 );
2709 }
2710
2711 #[test]
2712 fn stats_table_renders_without_runs() {
2713 let _guard = plain();
2714 let text = stats(&Stats::default());
2715 assert!(text.contains("0 total"));
2716 assert!(!text.contains("implementation"));
2717 assert!(!text.contains("node durations"));
2718 }
2719
2720 #[test]
2721 fn repo_summary_lists_run_count_and_completion_rate() {
2722 let _guard = plain();
2723 let a = crate::stats::RepoStats {
2724 repo: PathBuf::from("/repos/a"),
2725 name: "a".to_owned(),
2726 stats: Stats {
2727 totals: crate::stats::Totals {
2728 runs: 4,
2729 merged: 2,
2730 ready: 1,
2731 ..Default::default()
2732 },
2733 ..Default::default()
2734 },
2735 };
2736 let text = repo_summary(&[a]);
2737 assert!(text.contains("repos"), "{text}");
2738 assert!(text.contains("a 4 runs 75% completion"), "{text}");
2739 }
2740
2741 #[test]
2742 fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2743 let _guard = plain();
2744 let a = crate::stats::RepoStats {
2745 repo: PathBuf::from("/roots/one/magi"),
2746 name: "magi".to_owned(),
2747 stats: Stats::default(),
2748 };
2749 let b = crate::stats::RepoStats {
2750 repo: PathBuf::from("/roots/two/magi"),
2751 name: "magi".to_owned(),
2752 stats: Stats::default(),
2753 };
2754 let text = repo_summary(&[a, b]);
2755 assert!(text.contains("magi (one)"), "{text}");
2756 assert!(text.contains("magi (two)"), "{text}");
2757 }
2758
2759 #[test]
2760 fn stats_table_renders_advisor_reflection() {
2761 let _guard = plain();
2762 let st = Stats {
2763 advisors: vec![crate::stats::AdvisorStats {
2764 agent: "alpha".to_owned(),
2765 seated: 3,
2766 proposed: 2,
2767 absent: 1,
2768 faint: 1,
2769 strong: 1,
2770 }],
2771 ..Default::default()
2772 };
2773 let text = stats(&st);
2774 assert!(text.contains("design deliberation"));
2775 assert!(
2776 text.contains("approximate"),
2777 "must note the heuristic: {text}"
2778 );
2779 assert!(text.contains("alpha"));
2780 assert!(text.contains("50%"));
2781 }
2782
2783 #[test]
2784 fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2785 let _guard = plain();
2790 let st = Stats {
2791 release_bumps: crate::stats::ReleaseBumpStats {
2792 merged: 3,
2793 ..Default::default()
2794 },
2795 ..Default::default()
2796 };
2797 let text = stats(&st);
2798 assert!(text.contains("release bumps"), "{text}");
2799 assert!(
2800 text.contains("0 of 3 merged run(s) recorded a bump"),
2801 "{text}"
2802 );
2803 }
2804
2805 #[test]
2806 fn stats_table_renders_release_bump_coverage_and_attention() {
2807 let _guard = plain();
2808 let st = Stats {
2809 release_bumps: crate::stats::ReleaseBumpStats {
2810 merged: 4,
2811 recorded: 2,
2812 pr_opened: 2,
2813 automerge_enabled: 1,
2814 merged_directly: 0,
2815 needs_attention: 1,
2816 },
2817 ..Default::default()
2818 };
2819 let text = stats(&st);
2820 assert!(text.contains("release bumps"), "{text}");
2821 assert!(
2822 text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2823 "{text}"
2824 );
2825 assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2826 }
2827
2828 #[test]
2829 fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2830 let _guard = plain();
2831 let st = Stats {
2832 nodes: vec![
2833 crate::stats::NodeDuration {
2834 node: "implement".to_owned(),
2835 runs: 2,
2836 total_secs: 200,
2837 max_secs: 150,
2838 single: 1,
2839 },
2840 crate::stats::NodeDuration {
2841 node: "gate".to_owned(),
2842 runs: 0,
2843 total_secs: 0,
2844 max_secs: 0,
2845 single: 3,
2846 },
2847 ],
2848 ..Default::default()
2849 };
2850 let text = stats(&st);
2851 assert!(text.contains("node durations"));
2852 assert!(text.contains("implement"));
2853 assert!(text.contains("2 runs"));
2854 assert!(text.contains('█'), "the leader must draw a bar: {text}");
2855 assert!(text.contains("1 unmeasured"));
2856 assert!(text.contains("gate"));
2857 assert!(text.contains("3 unmeasured"));
2858 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2859 }
2860
2861 #[test]
2862 fn implementation_rates_use_independent_low_n_boundaries() {
2863 let _guard = plain();
2864 for (competitive_n, solo_n) in [(9, 10), (10, 9)] {
2865 let text = stats(&Stats {
2866 agents: vec![crate::stats::AgentStats {
2867 agent: "alpha".to_owned(),
2868 competitive_entered: competitive_n,
2869 competitive_wins: 1,
2870 solo_entered: solo_n,
2871 solo_adopted: 2,
2872 ..Default::default()
2873 }],
2874 ..Default::default()
2875 });
2876 let (competitive, solo) = text
2877 .split_once("implementation (solo runs, completion/adoption rate)")
2878 .unwrap();
2879 for (section, n, numerator) in [(competitive, competitive_n, 1), (solo, solo_n, 2)] {
2880 let row = section.lines().find(|line| line.contains("alpha")).unwrap();
2881 let cells: Vec<_> = row.split_whitespace().collect();
2882 assert_eq!(cells[1], numerator.to_string());
2883 assert_eq!(cells[2], n.to_string());
2884 assert_eq!(row.contains("low n"), n < 10);
2885 }
2886 }
2887 }
2888
2889 #[test]
2890 fn stats_table_renders_competitive_and_solo_tables_with_low_n() {
2891 let _guard = plain();
2892 let st = Stats {
2893 agents: vec![
2894 crate::stats::AgentStats {
2895 agent: "alpha".to_owned(),
2896 entered: 15,
2897 wins: 10,
2898 empty: 0,
2899 competitive_entered: 12,
2900 competitive_wins: 9,
2901 solo_entered: 3,
2902 solo_adopted: 1,
2903 },
2904 crate::stats::AgentStats {
2905 agent: "beta".to_owned(),
2906 entered: 5,
2907 wins: 2,
2908 empty: 1,
2909 competitive_entered: 4,
2910 competitive_wins: 2,
2911 solo_entered: 1,
2912 solo_adopted: 0,
2913 },
2914 ],
2915 ..Default::default()
2916 };
2917 let text = stats(&st);
2918 assert!(text.contains("implementation (competitive runs, 2+ candidates)"));
2919 assert!(text.contains("alpha"));
2920 assert!(text.contains("75%"));
2921 assert!(text.contains("beta"));
2922 assert!(text.contains("50%"));
2923 assert!(text.contains("implementation (solo runs, completion/adoption rate)"));
2924 assert!(text.contains("33%"));
2925 assert!(text.contains("low n"));
2926 }
2927}