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