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