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