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,
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!(
797 s,
798 "{}",
799 dim(&crate::run::summarize_verify(&o.output_tail, 2_000))
800 );
801 }
802 }
803 for rec in &r.reviews {
804 if let Some(vote) = rec.vote {
805 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
806 }
807 for f in &rec.findings {
808 let adopted = r
809 .fix
810 .as_ref()
811 .is_some_and(|fix| fix.addressed.contains(&f.id));
812 let _ = writeln!(
813 s,
814 " {} [{:?}] {}{}",
815 dim(&f.id),
816 f.severity,
817 f.title,
818 if adopted {
819 green(" fixed")
820 } else {
821 String::new()
822 }
823 );
824 }
825 }
826 if let Some(fix) = &r.fix {
827 for rej in &fix.rejected {
828 let _ = writeln!(
829 s,
830 " {} {}: {}",
831 dim(&rej.id),
832 yellow("declined"),
833 rej.why
834 );
835 }
836 }
837 if !r.reconsideration.is_empty() {
841 let _ = writeln!(s, " {}", dim("reconsideration:"));
842 for rv in &r.reconsideration {
843 match rv.vote {
844 Some(v) => {
845 let _ = writeln!(
846 s,
847 " review-{} -> {} {}",
848 rv.reviewer,
849 vote_tag(v),
850 rv.reason
851 );
852 }
853 None => {
854 let _ = writeln!(
855 s,
856 " review-{} -> {}",
857 rv.reviewer,
858 red(&format!(
859 "no revote ({})",
860 rv.failed.as_deref().unwrap_or("unknown")
861 ))
862 );
863 }
864 }
865 }
866 }
867 }
868 if state.handed_off_with_open_findings() {
869 let _ = writeln!(
870 s,
871 "\n {}",
872 yellow(&format!(
873 "handed off with {} finding(s) still open — gate and e2e were green; \
874 see above for what a person should still look at",
875 state.open_findings().len()
876 ))
877 );
878 }
879 }
880
881 if !state.merge_repairs.is_empty() {
882 let _ = writeln!(s, "\n{}", bold("merge-gate repairs"));
883 for r in &state.merge_repairs {
884 let _ = writeln!(
885 s,
886 " question {} head {} {:?}\n {}",
887 r.question,
888 crate::run::short_of(&r.head),
889 r.phase,
890 r.pr
891 );
892 if let Some(why) = &r.failure {
893 let _ = writeln!(s, " held: {why}");
894 }
895 if let Some(round) = r.reviews.last() {
896 for f in round.reviews.iter().flat_map(|rec| &rec.findings) {
897 let _ = writeln!(s, " original [{}] {:?}: {}", f.id, f.severity, f.title);
898 }
899 for (seat, _, vote) in round.final_votes() {
900 let _ = writeln!(s, " original reviewer #{seat}: {:?}", vote);
901 }
902 }
903 }
904 s.push_str(
905 " Repair never approves a merge; updated results require a fresh owner decision.\n",
906 );
907 }
908
909 if !state.operator_fixes.is_empty() {
910 let _ = writeln!(s, "\n{}", bold("operator fix(es)"));
911 for (i, req) in state.operator_fixes.iter().enumerate() {
912 let _ = writeln!(
913 s,
914 " [{}] {} finding(s) at {}{}",
915 i + 1,
916 req.findings.len(),
917 req.requested_at
918 .to_zoned(jiff::tz::TimeZone::system())
919 .strftime("%Y-%m-%d %H:%M:%S"),
920 if req.stale {
921 yellow(" stale head, --allow-stale used")
922 } else {
923 String::new()
924 }
925 );
926 let _ = writeln!(s, " reason: {}", req.reason);
927 for f in &req.findings {
928 let outcome = match &f.outcome {
929 OperatorFixOutcome::Pending => yellow("pending"),
930 OperatorFixOutcome::Addressed => green("addressed"),
931 OperatorFixOutcome::Rejected { why } => red(&format!("rejected: {why}")),
932 OperatorFixOutcome::Unreported => {
933 red("unreported — no adoption report came back")
934 }
935 };
936 let _ = writeln!(
937 s,
938 " {} [{:?}] {} {outcome}",
939 dim(&f.id),
940 f.severity,
941 f.title
942 );
943 }
944 match &req.follow_up_review_run {
945 Some(id) => {
946 let _ = writeln!(s, " re-verified by run {id}");
947 }
948 None if req.fix.as_ref().is_some_and(|fx| fx.committed) => {
949 let _ = writeln!(
950 s,
951 " {}",
952 red("committed, but the follow-up review could not be opened")
953 );
954 }
955 None => {
956 let _ = writeln!(s, " no change committed; nothing to re-verify");
957 }
958 }
959 }
960 }
961
962 if let Some(bs) = &state.base_sync {
963 let _ = writeln!(s, "\n{}", bold("base sync"));
964 let status = if let Some(a) = &bs.already_in {
965 dim(&format!(
966 "already in {} as {} ({} match) - nothing left to land",
967 state.base_branch,
968 a.names(),
969 a.proof.as_str()
970 ))
971 } else if let Some(c) = &bs.conflict {
972 red(&format!("conflict: {}", first_line(c)))
973 } else if bs.behind == 0 {
974 green("in sync")
975 } else {
976 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
977 };
978 let _ = writeln!(
979 s,
980 " {} @ {} {status}{}",
981 state.base_branch,
982 short(&bs.tip),
983 if bs.attempts > 0 {
984 format!(" ({} rebase attempt(s))", bs.attempts)
985 } else {
986 String::new()
987 }
988 );
989 }
990
991 if !state.pre_gate.is_empty() {
992 let _ = writeln!(s, "\n{}", bold("pre_gate"));
993 for o in &state.pre_gate {
994 let _ = writeln!(
995 s,
996 " {} {}",
997 if o.ok() {
998 green("pass")
999 } else {
1000 yellow("warn")
1001 },
1002 o.command
1003 );
1004 if !o.ok() {
1005 let _ = writeln!(
1006 s,
1007 "{}",
1008 dim(&crate::run::summarize_verify(&o.output_tail, 2_000))
1009 );
1010 }
1011 }
1012 if let Some(c) = &state.pre_gate_commit {
1013 let _ = writeln!(s, " committed mechanical fixes @ {}", short(c));
1014 }
1015 }
1016
1017 match state.gate_status() {
1021 GateStatus::NotRun => {}
1022 GateStatus::NotConfigured => {
1023 let _ = writeln!(s, "\n{}", bold("gate"));
1024 let _ = writeln!(s, " {} no gate commands configured", dim("not tested"));
1025 }
1026 GateStatus::Passed | GateStatus::Failed => {
1027 let _ = writeln!(s, "\n{}", bold("gate"));
1028 for o in &state.gate {
1029 let _ = writeln!(
1030 s,
1031 " {} {}",
1032 if o.ok() { green("pass") } else { red("FAIL") },
1033 o.command
1034 );
1035 if !o.ok() {
1036 let _ = writeln!(
1037 s,
1038 "{}",
1039 dim(&crate::run::summarize_verify(&o.output_tail, 2_000))
1040 );
1041 }
1042 }
1043 }
1044 }
1045
1046 if let Some(m) = &state.merge {
1047 let _ = writeln!(s, "\n{}", bold("merge"));
1048 if m.mode == MergeMode::None {
1049 let _ = writeln!(
1053 s,
1054 " mode None {}",
1055 cyan("not landed — nothing to do by design")
1056 );
1057 if let Some(w) = state.winner() {
1058 let _ = writeln!(
1059 s,
1060 " branch {} still exists, unmerged into {}",
1061 w.branch, state.base_branch
1062 );
1063 }
1064 let _ = writeln!(
1070 s,
1071 " rebase onto {} before merging by hand{}",
1072 state.base_branch,
1073 if state.config.merge.style == MergeStyle::Squash {
1074 ", and pass an explicit commit message — a squash merge \
1075 otherwise inherits the candidate's placeholder subject"
1076 } else {
1077 ""
1078 }
1079 );
1080 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
1081 } else {
1082 let _ = writeln!(
1083 s,
1084 " mode {:?} {}\n {}",
1085 m.mode,
1086 if m.empty {
1087 yellow("empty candidate — nothing to land")
1088 } else if m.ok && state.status == RunStatus::Merged {
1089 green("merged")
1090 } else if m.ok && m.mode == MergeMode::Pr {
1091 yellow("published; not merged")
1092 } else {
1093 yellow("not merged")
1094 },
1095 m.detail.lines().next().unwrap_or("")
1096 );
1097 }
1098 if let Some(pr) = &state.pr
1099 && !pr.red_at_merge.is_empty()
1100 {
1101 let _ = writeln!(
1102 s,
1103 " {} {}",
1104 yellow("merged with red checks:"),
1105 pr.red_at_merge.join(", ")
1106 );
1107 }
1108 }
1109
1110 if !state.followups.is_empty() {
1111 let _ = writeln!(s, "\n{}", bold("follow-ups"));
1112 for f in &state.followups {
1113 let _ = writeln!(
1114 s,
1115 " {} {}",
1116 crate::queue::short(&f.task),
1117 f.findings.join(", ")
1118 );
1119 }
1120 }
1121
1122 if !state.bump_decision_attempts.is_empty() {
1123 let _ = writeln!(s, "\n{}", bold("release decision attempts"));
1124 for (n, attempt) in state.bump_decision_attempts.iter().enumerate() {
1125 let _ = writeln!(s, " {}. {}: {}", n + 1, attempt.agent, attempt.outcome);
1126 }
1127 }
1128 if let Some(b) = &state.release_bump {
1129 let _ = writeln!(s, "\n{}", bold("release bump"));
1130 if let Some(why) = &b.skipped {
1131 let _ = writeln!(
1132 s,
1133 " {} {why}",
1134 bold(&yellow("SKIPPED (no release was made):"))
1135 );
1136 }
1137 if let Some(v) = &b.version {
1138 let _ = writeln!(s, " version v{v}");
1139 }
1140 if let Some(url) = &b.pr_url {
1141 let _ = writeln!(s, " pr {url}");
1142 }
1143 if b.skipped.is_none() || b.pr_url.is_some() {
1144 let _ = writeln!(
1145 s,
1146 " automerge {}",
1147 if b.automerge_enabled {
1148 green("enabled")
1149 } else if b.merged_directly {
1150 green("not needed (CI was already green; merged directly)")
1151 } else if b.local {
1152 dim(
1153 "not used (release.mode = local: merged on your approval, released by magi serve)",
1154 )
1155 } else if b.problem.is_some() {
1156 bold(&red("FAILED"))
1157 } else {
1158 dim("not enabled")
1159 }
1160 );
1161 }
1162 if let Some(p) = &b.problem {
1163 let _ = writeln!(s, " {}", p.lines().next().unwrap_or(""));
1164 }
1165 if let Some(j) = &b.release {
1166 let state = if j.finished {
1167 "finished"
1168 } else {
1169 j.failed.as_deref().unwrap_or("unfinished")
1170 };
1171 let _ = writeln!(s, " release {} at {}: {state}", j.tag(), j.commit);
1172 for l in &j.log {
1173 let code = l.code.map_or("none".to_owned(), |c| c.to_string());
1174 let _ = write!(s, " {} (exit {code})", l.name);
1175 if let Some(o) = &l.output {
1176 let _ = write!(s, " output {o}");
1177 }
1178 let _ = writeln!(s);
1179 }
1180 }
1181 if let Some(a) = &b.action_required {
1182 let _ = writeln!(s, " {} {a}", bold(&yellow("action required:")));
1183 }
1184 }
1185
1186 if !state.leaks.is_empty() {
1187 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
1188 for l in &state.leaks {
1189 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
1190 }
1191 }
1192
1193 if let Some(w) = state.winner()
1194 && !w.folded
1195 {
1196 let _ = writeln!(
1197 s,
1198 "\n{} {}\n branch {}",
1199 bold("winner worktree"),
1200 w.worktree.display(),
1201 w.branch
1202 );
1203 }
1204 s.push_str(&jobs_section(state));
1205 s
1206}
1207
1208pub fn active_seats(state: &RunState, live: Liveness) -> String {
1224 let mut s = String::new();
1225 if let Some(block) = &state.resource_block {
1226 let _ = writeln!(
1227 s,
1228 "\n{}\n {}. Resume to retry the affected operation.",
1229 bold("waiting on a resource"),
1230 block.description()
1231 );
1232 }
1233 if state.active.is_empty() {
1234 return s;
1235 }
1236 let _ = writeln!(s, "\n{}", bold("running now"));
1237 let now = jiff::Timestamp::now();
1238 match live {
1239 Liveness::Live => {}
1240 Liveness::Dead => {
1241 let _ = writeln!(
1242 s,
1243 " {}",
1244 yellow(
1245 "no live daemon claims this run right now — likely left behind by a killed process"
1246 )
1247 );
1248 }
1249 Liveness::Unknown => {
1250 let overrun = if state.active_all_overrun(now) {
1256 " — every active seat has already run past its own timeout budget"
1257 } else {
1258 ""
1259 };
1260 let _ = writeln!(
1261 s,
1262 " {}",
1263 yellow(&format!(
1264 "whether a process is still driving this run could not be confirmed{overrun}"
1265 ))
1266 );
1267 }
1268 }
1269 for (seat, a) in state.seats_active() {
1270 let retry = if a.attempt > 0 {
1271 format!(" retry {}", a.attempt)
1272 } else {
1273 String::new()
1274 };
1275 if let Some(w) = state.resource_wait.as_ref().filter(|w| w.node == a.node) {
1277 let _ = writeln!(
1278 s,
1279 " {:<12} {:<12}{retry} waiting for the {} ({}), {}s of {}s; agent not started yet",
1280 seat,
1281 a.node,
1282 w.kind,
1283 w.holder,
1284 (now.as_second() - w.since.as_second()).max(0),
1285 w.limit_secs
1286 );
1287 continue;
1288 }
1289 let _ = writeln!(
1290 s,
1291 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
1292 seat,
1293 a.node,
1294 a.elapsed_secs(now),
1295 a.remaining_secs(now),
1296 a.timeout_secs
1297 );
1298 }
1299 for (task, a) in state.tasks_active() {
1300 let retry = if a.attempt > 0 {
1301 format!(" retry {}", a.attempt)
1302 } else {
1303 String::new()
1304 };
1305 let progress = match (a.index, a.total) {
1306 (Some(i), Some(t)) => format!(" ({i}/{t})"),
1307 _ => String::new(),
1308 };
1309 let _ = writeln!(
1310 s,
1311 " {:<12} {:<12}{retry}{progress} {}s elapsed, {}s left of {}s",
1312 task,
1313 a.node,
1314 a.elapsed_secs(now),
1315 a.remaining_secs(now),
1316 a.timeout_secs
1317 );
1318 if let Some(command) = &a.command {
1319 let _ = writeln!(s, " {command}");
1320 }
1321 }
1322 s
1323}
1324
1325fn fmt_secs(secs: i64) -> String {
1327 let secs = secs.max(0);
1328 let h = secs / 3600;
1329 let m = (secs % 3600) / 60;
1330 let s = secs % 60;
1331 if h > 0 {
1332 format!("{h}h{m:02}m")
1333 } else if m > 0 {
1334 format!("{m}m{s:02}s")
1335 } else {
1336 format!("{s}s")
1337 }
1338}
1339
1340fn bar(value: f64, max: f64, width: usize) -> String {
1345 if max <= 0.0 || value <= 0.0 {
1346 return String::new();
1347 }
1348 let cells = ((value / max) * width as f64)
1349 .round()
1350 .clamp(1.0, width as f64) as usize;
1351 "█".repeat(cells)
1352}
1353
1354pub fn repo_summary(repos: &[RepoStats]) -> String {
1360 let mut s = String::new();
1361 let _ = writeln!(s, "{}", bold("repos"));
1362
1363 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1367 for r in repos {
1368 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
1369 }
1370
1371 for r in repos {
1372 let label = if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
1373 match r.repo.parent().and_then(|p| p.file_name()) {
1374 Some(parent) => format!("{} ({})", r.name, parent.to_string_lossy()),
1375 None => r.name.clone(),
1376 }
1377 } else {
1378 r.name.clone()
1379 };
1380 let _ = writeln!(
1381 s,
1382 " {} {} runs {:.0}% completion",
1383 label,
1384 r.stats.totals.runs,
1385 r.stats.totals.completion_rate()
1386 );
1387 }
1388 s
1389}
1390
1391pub fn stats(stats: &Stats) -> String {
1393 let t = &stats.totals;
1394 let mut s = String::new();
1395 let _ = writeln!(s, "{}", bold("runs"));
1396 let _ = writeln!(
1397 s,
1398 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1399 t.runs,
1400 t.merged,
1401 t.ready,
1402 t.blocked,
1403 t.failed,
1404 t.completion_rate()
1405 );
1406 if t.tallied > 0 {
1407 let _ = writeln!(
1408 s,
1409 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1410 {} of those changed a mind, {} converged to unanimous",
1411 t.tallied,
1412 t.split,
1413 t.split_rate(),
1414 t.deliberated,
1415 t.minds_changed,
1416 t.converged
1417 );
1418 }
1419
1420 if !stats.retired_hidden.is_empty() {
1421 let n = stats.retired_hidden.len();
1422 let _ = writeln!(
1423 s,
1424 "{n} retired agent{} hidden: {} (magi stats --all to show)\n",
1425 if n == 1 { "" } else { "s" },
1426 stats.retired_hidden.join(", ")
1427 );
1428 }
1429
1430 let has_competitive = stats.agents.iter().any(|a| a.competitive_entered > 0);
1431 if has_competitive {
1432 let mut comp_agents = stats.agents.clone();
1433 comp_agents.retain(|a| a.competitive_entered > 0);
1434 comp_agents.sort_by(|a, b| {
1435 let a_tier = if a.competitive_entered < 10 { 1 } else { 0 };
1436 let b_tier = if b.competitive_entered < 10 { 1 } else { 0 };
1437 a_tier
1438 .cmp(&b_tier)
1439 .then(
1440 b.competitive_win_rate()
1441 .total_cmp(&a.competitive_win_rate()),
1442 )
1443 .then(b.competitive_entered.cmp(&a.competitive_entered))
1444 .then(a.agent.cmp(&b.agent))
1445 });
1446
1447 let _ = writeln!(
1448 s,
1449 "\n{}",
1450 bold("implementation (competitive runs, 2+ candidates)")
1451 );
1452 let _ = writeln!(
1453 s,
1454 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1455 "agent", "won", "entered", "rate", "empty"
1456 );
1457 for a in &comp_agents {
1458 let low_n_marker = if a.competitive_entered < 10 {
1459 format!(" {}", dim("low n"))
1460 } else {
1461 String::new()
1462 };
1463 let _ = writeln!(
1464 s,
1465 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}{}",
1466 a.agent,
1467 a.competitive_wins,
1468 a.competitive_entered,
1469 a.competitive_win_rate(),
1470 a.empty,
1471 low_n_marker
1472 );
1473 }
1474 }
1475
1476 let has_solo = stats.agents.iter().any(|a| a.solo_entered > 0);
1477 if has_solo {
1478 let mut solo_agents = stats.agents.clone();
1479 solo_agents.retain(|a| a.solo_entered > 0);
1480 solo_agents.sort_by(|a, b| {
1481 let a_tier = if a.solo_entered < 10 { 1 } else { 0 };
1482 let b_tier = if b.solo_entered < 10 { 1 } else { 0 };
1483 a_tier
1484 .cmp(&b_tier)
1485 .then(b.solo_adoption_rate().total_cmp(&a.solo_adoption_rate()))
1486 .then(b.solo_entered.cmp(&a.solo_entered))
1487 .then(a.agent.cmp(&b.agent))
1488 });
1489
1490 let _ = writeln!(
1491 s,
1492 "\n{}",
1493 bold("implementation (solo runs, completion/adoption rate)")
1494 );
1495 let _ = writeln!(
1496 s,
1497 " {:<14}{:>8}{:>8}{:>8}",
1498 "agent", "adopted", "entered", "rate"
1499 );
1500 for a in &solo_agents {
1501 let low_n_marker = if a.solo_entered < 10 {
1502 format!(" {}", dim("low n"))
1503 } else {
1504 String::new()
1505 };
1506 let _ = writeln!(
1507 s,
1508 " {:<14}{:>8}{:>8}{:>7.0}%{}",
1509 a.agent,
1510 a.solo_adopted,
1511 a.solo_entered,
1512 a.solo_adoption_rate(),
1513 low_n_marker
1514 );
1515 }
1516 }
1517
1518 if !stats.reviewers.is_empty() {
1519 let _ = writeln!(s, "\n{}", bold("review"));
1520 let _ = writeln!(
1521 s,
1522 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1523 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1524 );
1525 for r in &stats.reviewers {
1526 let _ = writeln!(
1527 s,
1528 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1529 r.agent,
1530 r.rounds,
1531 r.submitted,
1532 r.adopted_per_round(),
1533 r.precision(),
1534 r.unique_rate(),
1535 r.timeout_rate()
1536 );
1537 }
1538 }
1539
1540 if !stats.advisors.is_empty() {
1541 let _ = writeln!(
1542 s,
1543 "\n{} {}",
1544 bold("design deliberation"),
1545 dim(
1546 "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1547 )
1548 );
1549 let _ = writeln!(
1550 s,
1551 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1552 "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1553 );
1554 for a in &stats.advisors {
1555 let _ = writeln!(
1556 s,
1557 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1558 a.agent,
1559 a.seated,
1560 a.proposed,
1561 a.strong,
1562 a.faint,
1563 a.absent,
1564 a.reflection_rate()
1565 );
1566 }
1567 }
1568
1569 if stats.release_bumps.merged > 0 {
1570 let b = &stats.release_bumps;
1571 let _ = writeln!(s, "\n{}", bold("release bumps"));
1572 let _ = writeln!(
1573 s,
1574 " {} of {} merged run(s) recorded a bump ({:.0}%)",
1575 b.recorded,
1576 b.merged,
1577 b.coverage_rate()
1578 );
1579 let _ = writeln!(
1580 s,
1581 " {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1582 b.pr_opened,
1583 b.automerge_enabled,
1584 b.automerge_rate(),
1585 b.merged_directly
1586 );
1587 let _ = writeln!(
1588 s,
1589 " {} clean, {} needed a human ({:.0}%)",
1590 b.clean(),
1591 b.needs_attention,
1592 b.attention_rate()
1593 );
1594 }
1595
1596 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1597 let _ = writeln!(s, "\n{}", bold("verification"));
1598 let _ = writeln!(
1599 s,
1600 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1601 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1602 stats.e2e.rounds,
1603 stats.e2e.failures,
1604 stats.e2e.sole_detections,
1605 stats.e2e.sole_rate(),
1606 stats.e2e.deferred
1607 );
1608 }
1609
1610 if !stats.nodes.is_empty() {
1611 let _ = writeln!(
1612 s,
1613 "\n{}",
1614 bold("node durations (first to last event, per run)")
1615 );
1616 let max_mean = stats
1617 .nodes
1618 .iter()
1619 .map(|n| n.mean_secs())
1620 .fold(0.0_f64, f64::max);
1621 for n in &stats.nodes {
1622 let unmeasured = if n.single > 0 {
1623 format!(" {}", dim(&format!("{} unmeasured", n.single)))
1624 } else {
1625 String::new()
1626 };
1627 let _ = writeln!(
1628 s,
1629 " {:<14}{:>6} runs{:>8} mean{:>8} max {}{unmeasured}",
1630 n.node,
1631 n.runs,
1632 fmt_secs(n.mean_secs().round() as i64),
1633 fmt_secs(n.max_secs),
1634 bar(n.mean_secs(), max_mean, 20)
1635 );
1636 }
1637 }
1638 s
1639}
1640
1641#[cfg(test)]
1642mod tests {
1643 use super::*;
1644 use crate::config::Config;
1645 use crate::run::{
1646 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1647 Tally,
1648 };
1649 use std::collections::BTreeMap;
1650 use std::path::PathBuf;
1651 use std::sync::{Mutex, MutexGuard};
1652
1653 static SERIAL: Mutex<()> = Mutex::new(());
1655
1656 fn plain() -> MutexGuard<'static, ()> {
1657 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1658 set_color(false);
1659 guard
1660 }
1661
1662 fn state() -> RunState {
1663 crate::run::pin_test_home();
1668 let mut s = RunState::new(
1669 PathBuf::from("/repo"),
1670 "main".to_owned(),
1671 "abcdef1234".to_owned(),
1672 "add retries to the uploader".to_owned(),
1673 Config::default(),
1674 );
1675 s.candidates = vec![Candidate {
1676 index: 0,
1677 label: 'A',
1678 agent: "opus".to_owned(),
1679 branch: "magi/x/A".to_owned(),
1680 worktree: PathBuf::from("/wt/A"),
1681 summary: String::new(),
1682 stat: String::new(),
1683 files: 3,
1684 commits: 2,
1685 empty: false,
1686 failed: None,
1687 verified_noop: None,
1688 duration_ms: 42_000,
1689 folded: false,
1690 }];
1691 s.tally = Some(Tally {
1692 first_choice: BTreeMap::from([('A', 3)]),
1693 borda: BTreeMap::new(),
1694 winner: 'A',
1695 rankings: 3,
1696 unanimous_initial: true,
1697 deliberated: false,
1698 changed_votes: 0,
1699 unanimous_final: true,
1700 tie_break: None,
1701 judges: 3,
1702 present: 3,
1703 quorum: 2,
1704 met_quorum: true,
1705 uncontested: None,
1706 });
1707 s
1708 }
1709
1710 #[test]
1711 fn run_report_names_the_winner_and_its_author() {
1712 let _guard = plain();
1713 let text = run(&state());
1714 assert!(text.contains("<- winner"), "{text}");
1715 assert!(text.contains("opus"));
1716 assert!(text.contains("3 files, 2 commits"));
1717 assert!(text.contains("winner A"));
1718 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1719 }
1720
1721 #[test]
1722 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1723 let _guard = plain();
1729 let mut s = state();
1730 s.candidates[0].summary =
1731 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1732 .to_owned();
1733 let text = run(&s);
1734 assert!(
1735 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1736 "{text}"
1737 );
1738 }
1739
1740 #[test]
1741 fn a_verified_noop_run_does_not_read_as_a_failure() {
1742 let _guard = plain();
1746 let mut s = state();
1747 s.status = RunStatus::VerifiedNoop;
1748 s.tally = None;
1749 s.candidates = vec![Candidate {
1750 index: 0,
1751 label: 'A',
1752 agent: "opus".to_owned(),
1753 branch: "magi/x/A".to_owned(),
1754 worktree: PathBuf::from("/wt/A"),
1755 summary: String::new(),
1756 stat: String::new(),
1757 files: 0,
1758 commits: 0,
1759 empty: true,
1760 failed: None,
1761 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1762 duration_ms: 9_000,
1763 folded: false,
1764 }];
1765 let text = run(&s);
1766 assert!(
1767 text.contains("agent-verified no-op"),
1768 "the status and the candidate flag must both say so: {text}"
1769 );
1770 assert!(
1771 text.contains("already fixed by b32cfc4"),
1772 "the evidence itself must be readable, not just the verdict: {text}"
1773 );
1774 assert!(
1775 !text.to_lowercase().contains("failed"),
1776 "a verified no-op must never read as the failure it is not: {text}"
1777 );
1778 }
1779
1780 #[test]
1781 fn colour_is_emitted_only_when_enabled() {
1782 let _guard = plain();
1783 set_color(true);
1784 let coloured = run(&state());
1785 set_color(false);
1786 let plain = run(&state());
1787 assert!(coloured.contains('\x1b'));
1788 assert!(!plain.contains('\x1b'));
1789 assert!(coloured.len() > plain.len());
1790 }
1791
1792 #[test]
1793 fn list_line_is_single_line() {
1794 let _guard = plain();
1795 let l = line(&state());
1796 assert_eq!(l.lines().count(), 1);
1797 assert!(l.contains("add retries"));
1798 assert!(l.contains("win A (opus)"));
1799 }
1800
1801 #[test]
1802 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1803 let _guard = plain();
1804 let mut s = state();
1805 s.tally = Some(Tally {
1806 first_choice: BTreeMap::from([('A', 0)]),
1807 borda: BTreeMap::new(),
1808 winner: 'A',
1809 rankings: 0,
1810 unanimous_initial: false,
1811 deliberated: false,
1812 changed_votes: 0,
1813 unanimous_final: false,
1814 tie_break: None,
1815 judges: 0,
1816 present: 0,
1817 quorum: 0,
1818 met_quorum: true,
1819 uncontested: Some(
1820 "only candidate A produced a usable change; no panel was asked".to_owned(),
1821 ),
1822 });
1823 let text = run(&s);
1824 assert!(
1825 !text.contains("0/3"),
1826 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1827 );
1828 assert!(!text.contains("no usable ranking"), "{text}");
1829 assert!(!text.contains("still split"), "{text}");
1830 assert!(!text.contains("BELOW QUORUM"), "{text}");
1831 assert!(
1832 text.contains("not needed"),
1833 "the report must say judging was skipped, not silent: {text}"
1834 );
1835 assert!(text.contains("winner A"));
1836 }
1837
1838 #[test]
1839 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1840 let _guard = plain();
1841 let mut s = state();
1842 s.tally = Some(Tally {
1843 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1844 borda: BTreeMap::new(),
1845 winner: 'A',
1846 rankings: 1,
1847 unanimous_initial: false,
1848 deliberated: false,
1849 changed_votes: 0,
1850 unanimous_final: false,
1851 tie_break: None,
1852 judges: 3,
1853 present: 1,
1854 quorum: 2,
1855 met_quorum: false,
1856 uncontested: None,
1857 });
1858 let text = run(&s);
1859 assert!(text.contains("1/3"), "{text}");
1860 assert!(
1861 text.contains("BELOW QUORUM"),
1862 "a real collapse must still be flagged: {text}"
1863 );
1864 assert!(
1865 !text.contains("not needed"),
1866 "a collapsed panel must not be described as one that was never asked: {text}"
1867 );
1868 }
1869
1870 #[test]
1871 fn a_mode_none_merge_does_not_read_as_landed() {
1872 let _guard = plain();
1873 let mut s = state();
1874 s.merge = Some(MergeOutcome {
1875 mode: crate::config::MergeMode::None,
1876 ok: true,
1877 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1878 empty: false,
1879 });
1880 let text = run(&s);
1881 assert!(
1882 !text.contains(" ok"),
1883 "mode none must not be shown as a landed merge: {text}"
1884 );
1885 assert!(text.contains("not landed"), "{text}");
1886 assert!(
1887 text.contains("branch magi/x/A"),
1888 "the report must say what's left behind: {text}"
1889 );
1890 assert!(
1891 text.contains("rebase"),
1892 "the report must point at the hand-landing steps: {text}"
1893 );
1894 assert!(
1895 !text.contains("placeholder subject"),
1896 "the default merge style is `merge`, which never inherits a \
1897 placeholder subject, so the squash caveat must not appear: {text}"
1898 );
1899 }
1900
1901 #[test]
1902 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1903 let _guard = plain();
1904 let mut s = state();
1905 s.config.merge.style = MergeStyle::Squash;
1906 s.merge = Some(MergeOutcome {
1907 mode: crate::config::MergeMode::None,
1908 ok: true,
1909 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1910 retries\""
1911 .to_owned(),
1912 empty: false,
1913 });
1914 let text = run(&s);
1915 assert!(
1916 text.contains("placeholder subject"),
1917 "a squash-style manual merge must warn about the missing message: {text}"
1918 );
1919 assert!(text.contains("--squash"), "{text}");
1920 }
1921
1922 #[test]
1923 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1924 let _guard = plain();
1925 let mut s = state();
1926 s.status = RunStatus::Ready;
1927 s.merge = Some(MergeOutcome {
1928 mode: crate::config::MergeMode::None,
1929 ok: true,
1930 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1931 empty: false,
1932 });
1933
1934 let list = line(&s);
1935 assert!(
1936 !list.contains(" ready "),
1937 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1938 );
1939 assert!(list.contains("no-op by design"), "{list}");
1940
1941 let full = run(&s);
1942 assert!(
1943 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1944 "the header line of `magi show` must not say plain ready either: {full}"
1945 );
1946 assert!(full.contains("no-op by design"), "{full}");
1947 }
1948
1949 #[test]
1950 fn an_ordinary_ready_run_still_reads_as_ready() {
1951 let _guard = plain();
1952 let mut s = state();
1953 s.status = RunStatus::Ready;
1954 s.merge = Some(MergeOutcome {
1958 mode: crate::config::MergeMode::Pr,
1959 ok: false,
1960 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1961 empty: false,
1962 });
1963
1964 let list = line(&s);
1965 assert!(list.contains("ready"), "{list}");
1966 assert!(!list.contains("no-op by design"), "{list}");
1967 }
1968
1969 #[test]
1970 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1971 let _guard = plain();
1972 let mut s = state();
1973 s.tally = Some(Tally {
1974 first_choice: BTreeMap::from([('A', 0)]),
1975 borda: BTreeMap::new(),
1976 winner: 'A',
1977 rankings: 0,
1978 unanimous_initial: false,
1979 deliberated: false,
1980 changed_votes: 0,
1981 unanimous_final: false,
1982 tie_break: None,
1983 judges: 0,
1984 present: 0,
1985 quorum: 0,
1986 met_quorum: true,
1987 uncontested: Some("only candidate A produced a usable change".to_owned()),
1988 });
1989 let l = line(&s);
1990 assert!(
1991 !l.contains("judges") && !l.contains("quorum"),
1992 "an uncontested run must not carry the same badge a short panel gets: {l}"
1993 );
1994 }
1995
1996 #[test]
1997 fn long_instructions_are_elided() {
1998 let _guard = plain();
1999 let mut s = state();
2000 s.instruction = "x".repeat(200);
2001 assert!(line(&s).contains('…'));
2002 }
2003
2004 #[test]
2005 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
2006 let _guard = plain();
2007 let mut lost = state();
2008 lost.reviews = vec![ReviewRound {
2009 round: 1,
2010 head: "abc1234".to_owned(),
2011 verified_head: None,
2012 verified_at: None,
2013 reviews: Vec::new(),
2014 e2e: Vec::new(),
2015 verify_retried: false,
2016 e2e_deferred: false,
2017 e2e_defer_reason: None,
2018 fix: Some(FixRecord {
2019 agent: "opus".to_owned(),
2020 addressed: Vec::new(),
2021 rejected: Vec::new(),
2022 notes: String::new(),
2023 committed: true,
2024 failed: Some("timed out".to_owned()),
2025 duration_ms: 0,
2026 continuation: None,
2027 }),
2028 blocking: 3,
2029 answered: 0,
2030 expected: 0,
2031 clean: false,
2032 progressed: false,
2033 vote_split: false,
2034 reconsideration: Vec::new(),
2035 verdict: None,
2036 }];
2037 let text = run(&lost);
2038 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2039 assert!(
2040 !text.contains("0 addressed"),
2041 "a lost report must never read as `0 addressed`: {text}"
2042 );
2043
2044 let mut rejected_all = state();
2045 rejected_all.reviews = vec![ReviewRound {
2046 round: 1,
2047 head: "abc1234".to_owned(),
2048 verified_head: None,
2049 verified_at: None,
2050 reviews: Vec::new(),
2051 e2e: Vec::new(),
2052 verify_retried: false,
2053 e2e_deferred: false,
2054 e2e_defer_reason: None,
2055 fix: Some(FixRecord {
2056 agent: "opus".to_owned(),
2057 addressed: Vec::new(),
2058 rejected: Vec::new(),
2059 notes: String::new(),
2060 committed: true,
2061 failed: None,
2062 duration_ms: 0,
2063 continuation: None,
2064 }),
2065 blocking: 3,
2066 answered: 0,
2067 expected: 0,
2068 clean: false,
2069 progressed: false,
2070 vote_split: false,
2071 reconsideration: Vec::new(),
2072 verdict: None,
2073 }];
2074 let text2 = run(&rejected_all);
2075 assert!(
2076 text2.contains("0 addressed / 0 rejected"),
2077 "a round the fixer actually reported on keeps the count: {text2}"
2078 );
2079 }
2080
2081 #[test]
2082 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
2083 use crate::run::ReviewRevoteRecord;
2084 use crate::verdict::ReviewVote;
2085
2086 let _guard = plain();
2087 let mut s = state();
2088 s.reviews = vec![ReviewRound {
2089 round: 1,
2090 head: "abc1234".to_owned(),
2091 verified_head: None,
2092 verified_at: None,
2093 reviews: vec![
2094 ReviewRecord {
2095 attempts: 0,
2096 reviewer: 1,
2097 agent: "alpha".to_owned(),
2098 summary: String::new(),
2099 findings: Vec::new(),
2100 vote: Some(ReviewVote::Approve),
2101 failed: None,
2102 duration_ms: 0,
2103 },
2104 ReviewRecord {
2105 attempts: 0,
2106 reviewer: 2,
2107 agent: "beta".to_owned(),
2108 summary: String::new(),
2109 findings: Vec::new(),
2110 vote: Some(ReviewVote::Reject),
2111 failed: None,
2112 duration_ms: 0,
2113 },
2114 ],
2115 e2e: Vec::new(),
2116 verify_retried: false,
2117 e2e_deferred: false,
2118 e2e_defer_reason: None,
2119 fix: None,
2120 blocking: 0,
2121 answered: 2,
2122 expected: 2,
2123 clean: false,
2124 progressed: false,
2125 vote_split: true,
2126 reconsideration: vec![ReviewRevoteRecord {
2127 reviewer: 2,
2128 agent: "beta".to_owned(),
2129 vote: Some(ReviewVote::ApproveWithFindings),
2130 reason: "the other seat's read holds up".to_owned(),
2131 failed: None,
2132 }],
2133 verdict: Some(ReviewVote::ApproveWithFindings),
2134 }];
2135 let text = run(&s);
2136 assert!(text.contains("review-1 vote"), "{text}");
2137 assert!(text.contains("review-2 vote"), "{text}");
2138 assert!(text.contains("panel split"), "{text}");
2139 assert!(text.contains("reconsideration"), "{text}");
2140 assert!(text.contains("the other seat's read holds up"), "{text}");
2141 }
2142
2143 #[test]
2144 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
2145 let _guard = plain();
2151 let mut s = state();
2152 s.reviews = vec![ReviewRound {
2153 round: 1,
2154 head: "abc1234".to_owned(),
2155 verified_head: None,
2156 verified_at: None,
2157 reviews: vec![
2158 ReviewRecord {
2159 attempts: 0,
2160 reviewer: 1,
2161 agent: "alpha".to_owned(),
2162 summary: String::new(),
2163 findings: Vec::new(),
2164 vote: None,
2165 failed: None,
2166 duration_ms: 0,
2167 },
2168 ReviewRecord {
2169 attempts: 0,
2170 reviewer: 2,
2171 agent: "beta".to_owned(),
2172 summary: String::new(),
2173 findings: Vec::new(),
2174 vote: None,
2175 failed: Some("agent timed out".to_owned()),
2176 duration_ms: 0,
2177 },
2178 ],
2179 e2e: Vec::new(),
2180 verify_retried: false,
2181 e2e_deferred: false,
2182 e2e_defer_reason: None,
2183 fix: Some(FixRecord {
2184 agent: "opus".to_owned(),
2185 addressed: Vec::new(),
2186 rejected: Vec::new(),
2187 notes: String::new(),
2188 committed: true,
2189 failed: Some("timed out".to_owned()),
2190 duration_ms: 0,
2191 continuation: None,
2192 }),
2193 blocking: 0,
2194 answered: 1,
2195 expected: 2,
2196 clean: false,
2197 progressed: true,
2198 vote_split: false,
2199 reconsideration: Vec::new(),
2200 verdict: None,
2201 }];
2202 let text = run(&s);
2203 assert!(text.contains("incomplete"), "{text}");
2204 assert!(text.contains("1/2 reviewers answered"), "{text}");
2205 assert!(text.contains("review-2: agent timed out"), "{text}");
2206 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2207 assert!(
2208 !text.contains("clean"),
2209 "a round missing half its panel must never render as clean: {text}"
2210 );
2211 }
2212
2213 #[test]
2214 fn a_build_failure_is_not_reported_as_a_test_failure() {
2215 let _guard = plain();
2216 let mut s = state();
2217 s.reviews = vec![ReviewRound {
2218 round: 1,
2219 head: "abc1234".to_owned(),
2220 verified_head: None,
2221 verified_at: None,
2222 reviews: Vec::new(),
2223 e2e: vec![CommandOutcome {
2224 command: "cargo test".to_owned(),
2225 code: Some(1),
2226 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2227 duration_ms: 100,
2228 resource_blocked: false,
2229 }],
2230 verify_retried: true,
2231 e2e_deferred: false,
2232 e2e_defer_reason: None,
2233 fix: None,
2234 blocking: 0,
2235 answered: 0,
2236 expected: 0,
2237 clean: false,
2238 progressed: false,
2239 vote_split: false,
2240 reconsideration: Vec::new(),
2241 verdict: None,
2242 }];
2243 let text = run(&s);
2244 assert!(text.contains("could not run"), "{text}");
2245 assert!(text.contains("retried once"), "{text}");
2246 assert!(!text.contains("e2e RED"), "{text}");
2247 }
2248
2249 #[test]
2250 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2251 let _guard = plain();
2252 let mut s = state();
2253 s.reviews = vec![ReviewRound {
2254 round: 1,
2255 head: "abc1234".to_owned(),
2256 verified_head: None,
2257 verified_at: None,
2258 reviews: Vec::new(),
2259 e2e: vec![CommandOutcome {
2260 command: "(waiting for the shared build cache)".to_owned(),
2261 code: None,
2262 output_tail: "held by run x node e2e seat e2e".to_owned(),
2263 duration_ms: 100,
2264 resource_blocked: true,
2265 }],
2266 verify_retried: false,
2267 e2e_deferred: false,
2268 e2e_defer_reason: None,
2269 fix: None,
2270 blocking: 0,
2271 answered: 0,
2272 expected: 0,
2273 clean: false,
2274 progressed: false,
2275 vote_split: false,
2276 reconsideration: Vec::new(),
2277 verdict: None,
2278 }];
2279 let text = run(&s);
2280 assert!(text.contains("resource unavailable"), "{text}");
2281 assert!(!text.contains("e2e RED"), "{text}");
2282 assert!(!text.contains("build/link failure"), "{text}");
2283 }
2284
2285 #[test]
2286 fn a_round_with_more_than_one_e2e_command_names_each_one() {
2287 let _guard = plain();
2292 let mut s = state();
2293 s.reviews = vec![ReviewRound {
2294 round: 1,
2295 head: "abc1234".to_owned(),
2296 verified_head: Some("abc1234".to_owned()),
2297 verified_at: Some(jiff::Timestamp::now()),
2298 reviews: Vec::new(),
2299 e2e: vec![
2300 CommandOutcome {
2301 command: "cargo test --locked --all-targets".to_owned(),
2302 code: Some(0),
2303 output_tail: String::new(),
2304 duration_ms: 0,
2305 resource_blocked: false,
2306 },
2307 CommandOutcome {
2308 command: "cargo make check".to_owned(),
2309 code: Some(1),
2310 output_tail: "clippy: unused import".to_owned(),
2311 duration_ms: 0,
2312 resource_blocked: false,
2313 },
2314 ],
2315 verify_retried: false,
2316 e2e_deferred: false,
2317 e2e_defer_reason: None,
2318 fix: None,
2319 blocking: 0,
2320 answered: 0,
2321 expected: 0,
2322 clean: false,
2323 progressed: false,
2324 vote_split: false,
2325 reconsideration: Vec::new(),
2326 verdict: None,
2327 }];
2328 let text = run(&s);
2329 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2330 assert!(text.contains("cargo make check"), "{text}");
2331 assert!(text.contains("clippy: unused import"), "{text}");
2332 }
2333
2334 #[test]
2335 fn a_declined_finding_shows_its_reason() {
2336 use crate::verdict::{Finding, Rejection, Severity};
2337
2338 let _guard = plain();
2339 let mut s = state();
2340 s.status = RunStatus::Ready;
2341 s.reviews = vec![ReviewRound {
2342 round: 1,
2343 head: "deadbee".to_owned(),
2344 verified_head: None,
2345 verified_at: None,
2346 reviews: vec![ReviewRecord {
2347 attempts: 0,
2348 reviewer: 1,
2349 agent: "alpha".to_owned(),
2350 summary: String::new(),
2351 findings: vec![Finding {
2352 id: "R1-1-1".to_owned(),
2353 severity: Severity::Major,
2354 file: None,
2355 line: None,
2356 title: "still open".to_owned(),
2357 detail: String::new(),
2358 }],
2359 vote: None,
2360 failed: None,
2361 duration_ms: 0,
2362 }],
2363 e2e: vec![CommandOutcome {
2364 command: "cargo test".to_owned(),
2365 code: Some(0),
2366 output_tail: String::new(),
2367 duration_ms: 0,
2368 resource_blocked: false,
2369 }],
2370 verify_retried: false,
2371 e2e_deferred: false,
2372 e2e_defer_reason: None,
2373 fix: Some(FixRecord {
2374 agent: "alpha".to_owned(),
2375 addressed: Vec::new(),
2376 rejected: vec![Rejection {
2377 id: "R1-1-2".to_owned(),
2378 why: "cannot be triggered from any caller".to_owned(),
2379 }],
2380 notes: String::new(),
2381 committed: true,
2382 failed: None,
2383 duration_ms: 0,
2384 continuation: None,
2385 }),
2386 blocking: 1,
2387 answered: 1,
2388 expected: 1,
2389 clean: false,
2390 progressed: true,
2391 vote_split: false,
2392 reconsideration: Vec::new(),
2393 verdict: None,
2394 }];
2395
2396 let text = run(&s);
2397 assert!(text.contains("R1-1-2"), "{text}");
2398 assert!(text.contains("cannot be triggered"), "{text}");
2399 assert!(text.contains("still open"), "{text}");
2400 assert!(
2401 text.contains("handed off"),
2402 "a mergeable run with an open round must say so: {text}"
2403 );
2404 }
2405
2406 #[test]
2407 fn a_failing_gate_command_shows_its_output() {
2408 let _guard = plain();
2409 let mut s = state();
2410 s.status = RunStatus::Blocked;
2411 s.gate = vec![CommandOutcome {
2412 command: "cargo make check".to_owned(),
2413 code: Some(101),
2414 output_tail: "error[E0308]: mismatched types".to_owned(),
2415 duration_ms: 0,
2416 resource_blocked: false,
2417 }];
2418 s.gate_ran = true;
2419
2420 let text = run(&s);
2421 assert!(text.contains("mismatched types"), "{text}");
2422 }
2423
2424 #[test]
2425 fn a_gate_with_no_commands_is_reported_as_not_tested() {
2426 let _guard = plain();
2427 let mut s = state();
2428 s.status = RunStatus::Ready;
2429 s.gate_ran = true;
2430 assert!(s.gate.is_empty());
2431
2432 let text = run(&s);
2433 assert!(
2434 text.contains("gate")
2435 && text.contains("no gate commands configured")
2436 && text.contains("not tested"),
2437 "an unconfigured gate must be explicit: {text}"
2438 );
2439 }
2440
2441 #[test]
2442 fn a_gate_that_has_not_run_yet_shows_nothing() {
2443 let _guard = plain();
2444 let s = state();
2445 assert!(!s.gate_ran);
2446 assert!(s.gate.is_empty());
2447
2448 let text = run(&s);
2449 assert!(
2450 !text.contains("no gate commands configured"),
2451 "an unattempted gate must not be shown as a pass: {text}"
2452 );
2453 }
2454
2455 #[test]
2456 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2457 let _guard = plain();
2458 let mut s = state();
2459 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2460 let text = active_seats(&s, Liveness::Live);
2461 assert!(text.contains("running now"));
2462 assert!(text.contains("judge-2"));
2463 assert!(text.contains("judge"));
2464 assert!(!text.contains("no live daemon"), "{text}");
2465 assert!(!text.contains("could not be confirmed"), "{text}");
2466 }
2467
2468 #[test]
2469 fn active_seats_flags_a_leftover_from_a_dead_process() {
2470 let _guard = plain();
2471 let mut s = state();
2472 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2473 let text = active_seats(&s, Liveness::Dead);
2474 assert!(
2475 text.contains("no live daemon"),
2476 "a stale entry must not read as running: {text}"
2477 );
2478 }
2479
2480 #[test]
2481 fn superseded_notice_names_the_run_that_replaced_it() {
2482 let _guard = plain();
2483 let text = superseded_notice(Some("bbbb"));
2484 assert!(text.contains("SUPERSEDED"), "{text}");
2485 assert!(text.contains("bbbb"), "{text}");
2486 }
2487
2488 #[test]
2489 fn superseded_notice_is_silent_for_the_latest_attempt() {
2490 let _guard = plain();
2491 assert_eq!(superseded_notice(None), "");
2492 }
2493
2494 #[test]
2495 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2496 let _guard = plain();
2497 let mut s = state();
2498 s.status = RunStatus::Reviewing;
2499 let text = line_with_liveness(&s, Liveness::Dead);
2500 assert!(text.contains("STALE"), "{text}");
2501 assert!(text.contains("resume required"), "{text}");
2502 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2503 }
2504
2505 #[test]
2510 fn active_seats_reports_uncertainty_without_claiming_death() {
2511 let _guard = plain();
2512 let mut s = state();
2513 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2514 let text = active_seats(&s, Liveness::Unknown);
2515 assert!(
2516 text.contains("could not be confirmed"),
2517 "an unproven state must read as uncertain, not dead: {text}"
2518 );
2519 assert!(!text.contains("no live daemon"), "{text}");
2520 }
2521
2522 #[test]
2523 fn resource_block_reports_saved_evidence_in_text_and_web() {
2524 use crate::run::{ResourceBlock, ResourceBlockCause};
2525 let legacy = serde_json::json!({
2526 "node": "fix", "kind": "shared build cache",
2527 "detail": "the run is parking", "at": "2026-10-24T09:01:51Z"
2528 });
2529 let block: ResourceBlock = serde_json::from_value(legacy).unwrap();
2530 assert_eq!(block.cause, ResourceBlockCause::Unrecorded);
2531 for (cause, detail, expected) in [
2532 (
2533 ResourceBlockCause::Unrecorded,
2534 "the run is parking",
2535 "cancelled for parking",
2536 ),
2537 (ResourceBlockCause::Unrecorded, "busy", "cause unrecorded"),
2538 (
2539 ResourceBlockCause::Other,
2540 "metadata write denied",
2541 "operation blocked",
2542 ),
2543 (
2544 ResourceBlockCause::Timeout {
2545 waited_secs: 12,
2546 limit_secs: 10,
2547 },
2548 "busy",
2549 "timed out after 12s (limit 10s)",
2550 ),
2551 (
2552 ResourceBlockCause::Cancelled {
2553 park: Some(crate::run::ParkCause::Operator),
2554 },
2555 "cancelled",
2556 "cancelled for parking",
2557 ),
2558 ] {
2559 let mut state = state();
2560 state.resource_block = Some(ResourceBlock {
2561 cause: cause.clone(),
2562 detail: detail.into(),
2563 ..block.clone()
2564 });
2565 let text = active_seats(&state, Liveness::Dead);
2566 assert!(text.contains(expected), "{text}");
2567 assert!(
2568 !text.contains("no agent was started"),
2569 "earlier nodes may have run"
2570 );
2571 assert_eq!(
2572 crate::report_view::build_with(&state, Liveness::Dead, None).active_seats,
2573 text
2574 );
2575 let saved = serde_json::to_value(&state.resource_block).unwrap();
2576 let reloaded: ResourceBlock = serde_json::from_value(saved).unwrap();
2577 assert_eq!(reloaded.cause, cause);
2578 }
2579 }
2580
2581 #[test]
2582 fn active_seats_is_empty_when_nothing_is_running() {
2583 let _guard = plain();
2584 assert_eq!(active_seats(&state(), Liveness::Live), "");
2585 }
2586
2587 #[test]
2593 fn active_seats_shows_a_running_verify_task_and_its_command() {
2594 let _guard = plain();
2595 let mut s = state();
2596 s.task_command(
2597 "e2e",
2598 "verify",
2599 0,
2600 "cargo test",
2601 2,
2602 3,
2603 std::time::Duration::from_secs(600),
2604 );
2605 let text = active_seats(&s, Liveness::Live);
2606 assert!(text.contains("e2e"), "{text}");
2607 assert!(text.contains("(2/3)"), "{text}");
2608 assert!(text.contains("cargo test"), "{text}");
2609 }
2610
2611 #[test]
2612 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2613 let _guard = plain();
2614 assert!(!run(&state()).contains("background jobs"));
2617 }
2618
2619 #[test]
2620 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2621 let _guard = plain();
2622 let mut s = state();
2623 s.config.agents.push(crate::config::AgentSpec {
2624 id: "codex-one".to_owned(),
2625 kind: crate::config::AgentKind::Codex,
2626 model: None,
2627 command: vec!["codex".to_owned()],
2628 extra_args: Vec::new(),
2629 env: BTreeMap::new(),
2630 prompt_delivery: None,
2631 max_turn_secs: None,
2632 });
2633 let text = run(&s);
2634 assert!(
2635 text.contains("background jobs"),
2636 "a run that could report this must not read the same as one that never could: \
2637 {text}"
2638 );
2639 assert!(text.contains("no completed command evidence yet"));
2640 }
2641
2642 #[test]
2643 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2644 let _guard = plain();
2645 let mut s = state();
2646 s.jobs = vec![
2647 crate::run::JobRecord {
2648 node: "implement".to_owned(),
2649 round: None,
2650 seat: "impl-A".to_owned(),
2651 id: "item49".to_owned(),
2652 description: "cargo test --test graph_cached_gate".to_owned(),
2653 checked_at: jiff::Timestamp::now(),
2654 status: crate::run::JobStatus::Completed,
2655 exit_code: Some(0),
2656 result_summary: "test result: 2 passed; 0 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: "item52".to_owned(),
2664 description: "cargo test --test graph_split".to_owned(),
2665 checked_at: jiff::Timestamp::now(),
2666 status: crate::run::JobStatus::Failed,
2667 exit_code: Some(101),
2668 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2669 source: "codex".to_owned(),
2670 },
2671 crate::run::JobRecord {
2672 node: "fix".to_owned(),
2673 round: None,
2674 seat: "impl-A".to_owned(),
2675 id: "item60".to_owned(),
2676 description: "cargo build".to_owned(),
2677 checked_at: jiff::Timestamp::now(),
2678 status: crate::run::JobStatus::Unknown,
2679 exit_code: None,
2680 result_summary: String::new(),
2681 source: "codex".to_owned(),
2682 },
2683 ];
2684 let text = run(&s);
2685 assert!(text.contains("background jobs"));
2686 assert!(text.contains("item49"));
2687 assert!(text.contains("item52"));
2688 assert!(text.contains("item60"));
2689 assert!(text.contains("completed"));
2693 assert!(text.contains("failed"));
2694 assert!(text.contains("unknown"));
2695 assert!(text.contains("adapter coverage"));
2697 }
2698
2699 #[test]
2700 fn a_jobs_own_round_is_shown_when_known() {
2701 let _guard = plain();
2702 let mut s = state();
2703 s.jobs = vec![crate::run::JobRecord {
2704 node: "review".to_owned(),
2705 round: Some(2),
2706 seat: "review-1".to_owned(),
2707 id: "item9".to_owned(),
2708 description: "cargo test --test graph_cached_gate".to_owned(),
2709 checked_at: jiff::Timestamp::now(),
2710 status: crate::run::JobStatus::Completed,
2711 exit_code: Some(0),
2712 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2713 source: "codex".to_owned(),
2714 }];
2715 let text = run(&s);
2716 assert!(
2717 text.contains("round 2"),
2718 "the round this seat's own command ran in must be visible, distinct from magi's \
2719 own recorded verify: {text}"
2720 );
2721 }
2722
2723 #[test]
2724 fn stats_table_renders_without_runs() {
2725 let _guard = plain();
2726 let text = stats(&Stats::default());
2727 assert!(text.contains("0 total"));
2728 assert!(!text.contains("implementation"));
2729 assert!(!text.contains("node durations"));
2730 }
2731
2732 #[test]
2733 fn repo_summary_lists_run_count_and_completion_rate() {
2734 let _guard = plain();
2735 let a = crate::stats::RepoStats {
2736 repo: PathBuf::from("/repos/a"),
2737 name: "a".to_owned(),
2738 stats: Stats {
2739 totals: crate::stats::Totals {
2740 runs: 4,
2741 merged: 2,
2742 ready: 1,
2743 ..Default::default()
2744 },
2745 ..Default::default()
2746 },
2747 };
2748 let text = repo_summary(&[a]);
2749 assert!(text.contains("repos"), "{text}");
2750 assert!(text.contains("a 4 runs 75% completion"), "{text}");
2751 }
2752
2753 #[test]
2754 fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2755 let _guard = plain();
2756 let a = crate::stats::RepoStats {
2757 repo: PathBuf::from("/roots/one/magi"),
2758 name: "magi".to_owned(),
2759 stats: Stats::default(),
2760 };
2761 let b = crate::stats::RepoStats {
2762 repo: PathBuf::from("/roots/two/magi"),
2763 name: "magi".to_owned(),
2764 stats: Stats::default(),
2765 };
2766 let text = repo_summary(&[a, b]);
2767 assert!(text.contains("magi (one)"), "{text}");
2768 assert!(text.contains("magi (two)"), "{text}");
2769 }
2770
2771 #[test]
2772 fn stats_table_renders_advisor_reflection() {
2773 let _guard = plain();
2774 let st = Stats {
2775 advisors: vec![crate::stats::AdvisorStats {
2776 agent: "alpha".to_owned(),
2777 seated: 3,
2778 proposed: 2,
2779 absent: 1,
2780 faint: 1,
2781 strong: 1,
2782 }],
2783 ..Default::default()
2784 };
2785 let text = stats(&st);
2786 assert!(text.contains("design deliberation"));
2787 assert!(
2788 text.contains("approximate"),
2789 "must note the heuristic: {text}"
2790 );
2791 assert!(text.contains("alpha"));
2792 assert!(text.contains("50%"));
2793 }
2794
2795 #[test]
2796 fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2797 let _guard = plain();
2802 let st = Stats {
2803 release_bumps: crate::stats::ReleaseBumpStats {
2804 merged: 3,
2805 ..Default::default()
2806 },
2807 ..Default::default()
2808 };
2809 let text = stats(&st);
2810 assert!(text.contains("release bumps"), "{text}");
2811 assert!(
2812 text.contains("0 of 3 merged run(s) recorded a bump"),
2813 "{text}"
2814 );
2815 }
2816
2817 #[test]
2818 fn stats_table_renders_release_bump_coverage_and_attention() {
2819 let _guard = plain();
2820 let st = Stats {
2821 release_bumps: crate::stats::ReleaseBumpStats {
2822 merged: 4,
2823 recorded: 2,
2824 pr_opened: 2,
2825 automerge_enabled: 1,
2826 merged_directly: 0,
2827 needs_attention: 1,
2828 },
2829 ..Default::default()
2830 };
2831 let text = stats(&st);
2832 assert!(text.contains("release bumps"), "{text}");
2833 assert!(
2834 text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2835 "{text}"
2836 );
2837 assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2838 }
2839
2840 #[test]
2841 fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2842 let _guard = plain();
2843 let st = Stats {
2844 nodes: vec![
2845 crate::stats::NodeDuration {
2846 node: "implement".to_owned(),
2847 runs: 2,
2848 total_secs: 200,
2849 max_secs: 150,
2850 single: 1,
2851 },
2852 crate::stats::NodeDuration {
2853 node: "gate".to_owned(),
2854 runs: 0,
2855 total_secs: 0,
2856 max_secs: 0,
2857 single: 3,
2858 },
2859 ],
2860 ..Default::default()
2861 };
2862 let text = stats(&st);
2863 assert!(text.contains("node durations"));
2864 assert!(text.contains("implement"));
2865 assert!(text.contains("2 runs"));
2866 assert!(text.contains('█'), "the leader must draw a bar: {text}");
2867 assert!(text.contains("1 unmeasured"));
2868 assert!(text.contains("gate"));
2869 assert!(text.contains("3 unmeasured"));
2870 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2871 }
2872
2873 #[test]
2874 fn implementation_rates_use_independent_low_n_boundaries() {
2875 let _guard = plain();
2876 for (competitive_n, solo_n) in [(9, 10), (10, 9)] {
2877 let text = stats(&Stats {
2878 agents: vec![crate::stats::AgentStats {
2879 agent: "alpha".to_owned(),
2880 competitive_entered: competitive_n,
2881 competitive_wins: 1,
2882 solo_entered: solo_n,
2883 solo_adopted: 2,
2884 ..Default::default()
2885 }],
2886 ..Default::default()
2887 });
2888 let (competitive, solo) = text
2889 .split_once("implementation (solo runs, completion/adoption rate)")
2890 .unwrap();
2891 for (section, n, numerator) in [(competitive, competitive_n, 1), (solo, solo_n, 2)] {
2892 let row = section.lines().find(|line| line.contains("alpha")).unwrap();
2893 let cells: Vec<_> = row.split_whitespace().collect();
2894 assert_eq!(cells[1], numerator.to_string());
2895 assert_eq!(cells[2], n.to_string());
2896 assert_eq!(row.contains("low n"), n < 10);
2897 }
2898 }
2899 }
2900
2901 #[test]
2902 fn stats_table_renders_competitive_and_solo_tables_with_low_n() {
2903 let _guard = plain();
2904 let st = Stats {
2905 agents: vec![
2906 crate::stats::AgentStats {
2907 agent: "alpha".to_owned(),
2908 entered: 15,
2909 wins: 10,
2910 empty: 0,
2911 competitive_entered: 12,
2912 competitive_wins: 9,
2913 solo_entered: 3,
2914 solo_adopted: 1,
2915 },
2916 crate::stats::AgentStats {
2917 agent: "beta".to_owned(),
2918 entered: 5,
2919 wins: 2,
2920 empty: 1,
2921 competitive_entered: 4,
2922 competitive_wins: 2,
2923 solo_entered: 1,
2924 solo_adopted: 0,
2925 },
2926 ],
2927 ..Default::default()
2928 };
2929 let text = stats(&st);
2930 assert!(text.contains("implementation (competitive runs, 2+ candidates)"));
2931 assert!(text.contains("alpha"));
2932 assert!(text.contains("75%"));
2933 assert!(text.contains("beta"));
2934 assert!(text.contains("50%"));
2935 assert!(text.contains("implementation (solo runs, completion/adoption rate)"));
2936 assert!(text.contains("33%"));
2937 assert!(text.contains("low n"));
2938 }
2939}