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 !state.bump_decision_attempts.is_empty() {
1106 let _ = writeln!(s, "\n{}", bold("release decision attempts"));
1107 for (n, attempt) in state.bump_decision_attempts.iter().enumerate() {
1108 let _ = writeln!(s, " {}. {}: {}", n + 1, attempt.agent, attempt.outcome);
1109 }
1110 }
1111 if let Some(b) = &state.release_bump {
1112 let _ = writeln!(s, "\n{}", bold("release bump"));
1113 if let Some(v) = &b.version {
1114 let _ = writeln!(s, " version v{v}");
1115 }
1116 if let Some(url) = &b.pr_url {
1117 let _ = writeln!(s, " pr {url}");
1118 }
1119 let _ = writeln!(
1120 s,
1121 " automerge {}",
1122 if b.automerge_enabled {
1123 green("enabled")
1124 } else if b.merged_directly {
1125 green("not needed (CI was already green; merged directly)")
1126 } else if b.local {
1127 dim(
1128 "not used (release.mode = local: merged on your approval, released by magi serve)",
1129 )
1130 } else if b.problem.is_some() {
1131 bold(&red("FAILED"))
1132 } else {
1133 dim("not enabled")
1134 }
1135 );
1136 if let Some(p) = &b.problem {
1137 let _ = writeln!(s, " {}", p.lines().next().unwrap_or(""));
1138 }
1139 if let Some(j) = &b.release {
1140 let state = if j.finished {
1141 "finished"
1142 } else {
1143 j.failed.as_deref().unwrap_or("unfinished")
1144 };
1145 let _ = writeln!(s, " release {} at {}: {state}", j.tag(), j.commit);
1146 for l in &j.log {
1147 let code = l.code.map_or("none".to_owned(), |c| c.to_string());
1148 let _ = write!(s, " {} (exit {code})", l.name);
1149 if let Some(o) = &l.output {
1150 let _ = write!(s, " output {o}");
1151 }
1152 let _ = writeln!(s);
1153 }
1154 }
1155 if let Some(a) = &b.action_required {
1156 let _ = writeln!(s, " {} {a}", bold(&yellow("action required:")));
1157 }
1158 }
1159
1160 if !state.leaks.is_empty() {
1161 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
1162 for l in &state.leaks {
1163 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
1164 }
1165 }
1166
1167 if let Some(w) = state.winner()
1168 && !w.folded
1169 {
1170 let _ = writeln!(
1171 s,
1172 "\n{} {}\n branch {}",
1173 bold("winner worktree"),
1174 w.worktree.display(),
1175 w.branch
1176 );
1177 }
1178 s.push_str(&jobs_section(state));
1179 s
1180}
1181
1182pub fn active_seats(state: &RunState, live: Liveness) -> String {
1198 let mut s = String::new();
1199 if let Some(b) = &state.resource_block {
1202 let _ = writeln!(
1203 s,
1204 "\n{}\n {}: {} stayed busy past the resource-wait limit; no agent was started \
1205 ({}). Resume to ask the same agents again.",
1206 bold("waiting on a resource"),
1207 b.node,
1208 b.kind,
1209 b.detail
1210 );
1211 }
1212 if state.active.is_empty() {
1213 return s;
1214 }
1215 let _ = writeln!(s, "\n{}", bold("running now"));
1216 let now = jiff::Timestamp::now();
1217 match live {
1218 Liveness::Live => {}
1219 Liveness::Dead => {
1220 let _ = writeln!(
1221 s,
1222 " {}",
1223 yellow(
1224 "no live daemon claims this run right now — likely left behind by a killed process"
1225 )
1226 );
1227 }
1228 Liveness::Unknown => {
1229 let overrun = if state.active_all_overrun(now) {
1235 " — every active seat has already run past its own timeout budget"
1236 } else {
1237 ""
1238 };
1239 let _ = writeln!(
1240 s,
1241 " {}",
1242 yellow(&format!(
1243 "whether a process is still driving this run could not be confirmed{overrun}"
1244 ))
1245 );
1246 }
1247 }
1248 for (seat, a) in state.seats_active() {
1249 let retry = if a.attempt > 0 {
1250 format!(" retry {}", a.attempt)
1251 } else {
1252 String::new()
1253 };
1254 if let Some(w) = state.resource_wait.as_ref().filter(|w| w.node == a.node) {
1256 let _ = writeln!(
1257 s,
1258 " {:<12} {:<12}{retry} waiting for the {} ({}), {}s of {}s; agent not started yet",
1259 seat,
1260 a.node,
1261 w.kind,
1262 w.holder,
1263 (now.as_second() - w.since.as_second()).max(0),
1264 w.limit_secs
1265 );
1266 continue;
1267 }
1268 let _ = writeln!(
1269 s,
1270 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
1271 seat,
1272 a.node,
1273 a.elapsed_secs(now),
1274 a.remaining_secs(now),
1275 a.timeout_secs
1276 );
1277 }
1278 for (task, a) in state.tasks_active() {
1279 let retry = if a.attempt > 0 {
1280 format!(" retry {}", a.attempt)
1281 } else {
1282 String::new()
1283 };
1284 let progress = match (a.index, a.total) {
1285 (Some(i), Some(t)) => format!(" ({i}/{t})"),
1286 _ => String::new(),
1287 };
1288 let _ = writeln!(
1289 s,
1290 " {:<12} {:<12}{retry}{progress} {}s elapsed, {}s left of {}s",
1291 task,
1292 a.node,
1293 a.elapsed_secs(now),
1294 a.remaining_secs(now),
1295 a.timeout_secs
1296 );
1297 if let Some(command) = &a.command {
1298 let _ = writeln!(s, " {command}");
1299 }
1300 }
1301 s
1302}
1303
1304fn fmt_secs(secs: i64) -> String {
1306 let secs = secs.max(0);
1307 let h = secs / 3600;
1308 let m = (secs % 3600) / 60;
1309 let s = secs % 60;
1310 if h > 0 {
1311 format!("{h}h{m:02}m")
1312 } else if m > 0 {
1313 format!("{m}m{s:02}s")
1314 } else {
1315 format!("{s}s")
1316 }
1317}
1318
1319fn bar(value: f64, max: f64, width: usize) -> String {
1324 if max <= 0.0 || value <= 0.0 {
1325 return String::new();
1326 }
1327 let cells = ((value / max) * width as f64)
1328 .round()
1329 .clamp(1.0, width as f64) as usize;
1330 "█".repeat(cells)
1331}
1332
1333pub fn repo_summary(repos: &[RepoStats]) -> String {
1339 let mut s = String::new();
1340 let _ = writeln!(s, "{}", bold("repos"));
1341
1342 let mut name_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1346 for r in repos {
1347 *name_counts.entry(r.name.as_str()).or_insert(0) += 1;
1348 }
1349
1350 for r in repos {
1351 let label = if name_counts.get(r.name.as_str()).copied().unwrap_or(0) > 1 {
1352 match r.repo.parent().and_then(|p| p.file_name()) {
1353 Some(parent) => format!("{} ({})", r.name, parent.to_string_lossy()),
1354 None => r.name.clone(),
1355 }
1356 } else {
1357 r.name.clone()
1358 };
1359 let _ = writeln!(
1360 s,
1361 " {} {} runs {:.0}% completion",
1362 label,
1363 r.stats.totals.runs,
1364 r.stats.totals.completion_rate()
1365 );
1366 }
1367 s
1368}
1369
1370pub fn stats(stats: &Stats) -> String {
1372 let t = &stats.totals;
1373 let mut s = String::new();
1374 let _ = writeln!(s, "{}", bold("runs"));
1375 let _ = writeln!(
1376 s,
1377 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
1378 t.runs,
1379 t.merged,
1380 t.ready,
1381 t.blocked,
1382 t.failed,
1383 t.completion_rate()
1384 );
1385 if t.tallied > 0 {
1386 let _ = writeln!(
1387 s,
1388 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
1389 {} of those changed a mind, {} converged to unanimous",
1390 t.tallied,
1391 t.split,
1392 t.split_rate(),
1393 t.deliberated,
1394 t.minds_changed,
1395 t.converged
1396 );
1397 }
1398
1399 if !stats.retired_hidden.is_empty() {
1400 let n = stats.retired_hidden.len();
1401 let _ = writeln!(
1402 s,
1403 "{n} retired agent{} hidden: {} (magi stats --all to show)\n",
1404 if n == 1 { "" } else { "s" },
1405 stats.retired_hidden.join(", ")
1406 );
1407 }
1408
1409 let has_competitive = stats.agents.iter().any(|a| a.competitive_entered > 0);
1410 if has_competitive {
1411 let mut comp_agents = stats.agents.clone();
1412 comp_agents.retain(|a| a.competitive_entered > 0);
1413 comp_agents.sort_by(|a, b| {
1414 let a_tier = if a.competitive_entered < 10 { 1 } else { 0 };
1415 let b_tier = if b.competitive_entered < 10 { 1 } else { 0 };
1416 a_tier
1417 .cmp(&b_tier)
1418 .then(
1419 b.competitive_win_rate()
1420 .total_cmp(&a.competitive_win_rate()),
1421 )
1422 .then(b.competitive_entered.cmp(&a.competitive_entered))
1423 .then(a.agent.cmp(&b.agent))
1424 });
1425
1426 let _ = writeln!(
1427 s,
1428 "\n{}",
1429 bold("implementation (competitive runs, 2+ candidates)")
1430 );
1431 let _ = writeln!(
1432 s,
1433 " {:<14}{:>6}{:>8}{:>8}{:>8}",
1434 "agent", "won", "entered", "rate", "empty"
1435 );
1436 for a in &comp_agents {
1437 let low_n_marker = if a.competitive_entered < 10 {
1438 format!(" {}", dim("low n"))
1439 } else {
1440 String::new()
1441 };
1442 let _ = writeln!(
1443 s,
1444 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}{}",
1445 a.agent,
1446 a.competitive_wins,
1447 a.competitive_entered,
1448 a.competitive_win_rate(),
1449 a.empty,
1450 low_n_marker
1451 );
1452 }
1453 }
1454
1455 let has_solo = stats.agents.iter().any(|a| a.solo_entered > 0);
1456 if has_solo {
1457 let mut solo_agents = stats.agents.clone();
1458 solo_agents.retain(|a| a.solo_entered > 0);
1459 solo_agents.sort_by(|a, b| {
1460 let a_tier = if a.solo_entered < 10 { 1 } else { 0 };
1461 let b_tier = if b.solo_entered < 10 { 1 } else { 0 };
1462 a_tier
1463 .cmp(&b_tier)
1464 .then(b.solo_adoption_rate().total_cmp(&a.solo_adoption_rate()))
1465 .then(b.solo_entered.cmp(&a.solo_entered))
1466 .then(a.agent.cmp(&b.agent))
1467 });
1468
1469 let _ = writeln!(
1470 s,
1471 "\n{}",
1472 bold("implementation (solo runs, completion/adoption rate)")
1473 );
1474 let _ = writeln!(
1475 s,
1476 " {:<14}{:>8}{:>8}{:>8}",
1477 "agent", "adopted", "entered", "rate"
1478 );
1479 for a in &solo_agents {
1480 let low_n_marker = if a.solo_entered < 10 {
1481 format!(" {}", dim("low n"))
1482 } else {
1483 String::new()
1484 };
1485 let _ = writeln!(
1486 s,
1487 " {:<14}{:>8}{:>8}{:>7.0}%{}",
1488 a.agent,
1489 a.solo_adopted,
1490 a.solo_entered,
1491 a.solo_adoption_rate(),
1492 low_n_marker
1493 );
1494 }
1495 }
1496
1497 if !stats.reviewers.is_empty() {
1498 let _ = writeln!(s, "\n{}", bold("review"));
1499 let _ = writeln!(
1500 s,
1501 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
1502 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
1503 );
1504 for r in &stats.reviewers {
1505 let _ = writeln!(
1506 s,
1507 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
1508 r.agent,
1509 r.rounds,
1510 r.submitted,
1511 r.adopted_per_round(),
1512 r.precision(),
1513 r.unique_rate(),
1514 r.timeout_rate()
1515 );
1516 }
1517 }
1518
1519 if !stats.advisors.is_empty() {
1520 let _ = writeln!(
1521 s,
1522 "\n{} {}",
1523 bold("design deliberation"),
1524 dim(
1525 "(approximate: reflection is read off word overlap with the synthesis brief, not a precise attribution)"
1526 )
1527 );
1528 let _ = writeln!(
1529 s,
1530 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>10}",
1531 "advisor", "seats", "proposed", "strong", "faint", "absent", "reflect%"
1532 );
1533 for a in &stats.advisors {
1534 let _ = writeln!(
1535 s,
1536 " {:<14}{:>7}{:>10}{:>8}{:>8}{:>9}{:>9.0}%",
1537 a.agent,
1538 a.seated,
1539 a.proposed,
1540 a.strong,
1541 a.faint,
1542 a.absent,
1543 a.reflection_rate()
1544 );
1545 }
1546 }
1547
1548 if stats.release_bumps.merged > 0 {
1549 let b = &stats.release_bumps;
1550 let _ = writeln!(s, "\n{}", bold("release bumps"));
1551 let _ = writeln!(
1552 s,
1553 " {} of {} merged run(s) recorded a bump ({:.0}%)",
1554 b.recorded,
1555 b.merged,
1556 b.coverage_rate()
1557 );
1558 let _ = writeln!(
1559 s,
1560 " {} release PR(s) opened, {} automerge enabled ({:.0}%), {} merged directly",
1561 b.pr_opened,
1562 b.automerge_enabled,
1563 b.automerge_rate(),
1564 b.merged_directly
1565 );
1566 let _ = writeln!(
1567 s,
1568 " {} clean, {} needed a human ({:.0}%)",
1569 b.clean(),
1570 b.needs_attention,
1571 b.attention_rate()
1572 );
1573 }
1574
1575 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
1576 let _ = writeln!(s, "\n{}", bold("verification"));
1577 let _ = writeln!(
1578 s,
1579 " {} rounds ran e2e, {} failed, {} of those with a clean static \
1580 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
1581 stats.e2e.rounds,
1582 stats.e2e.failures,
1583 stats.e2e.sole_detections,
1584 stats.e2e.sole_rate(),
1585 stats.e2e.deferred
1586 );
1587 }
1588
1589 if !stats.nodes.is_empty() {
1590 let _ = writeln!(
1591 s,
1592 "\n{}",
1593 bold("node durations (first to last event, per run)")
1594 );
1595 let max_mean = stats
1596 .nodes
1597 .iter()
1598 .map(|n| n.mean_secs())
1599 .fold(0.0_f64, f64::max);
1600 for n in &stats.nodes {
1601 let unmeasured = if n.single > 0 {
1602 format!(" {}", dim(&format!("{} unmeasured", n.single)))
1603 } else {
1604 String::new()
1605 };
1606 let _ = writeln!(
1607 s,
1608 " {:<14}{:>6} runs{:>8} mean{:>8} max {}{unmeasured}",
1609 n.node,
1610 n.runs,
1611 fmt_secs(n.mean_secs().round() as i64),
1612 fmt_secs(n.max_secs),
1613 bar(n.mean_secs(), max_mean, 20)
1614 );
1615 }
1616 }
1617 s
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622 use super::*;
1623 use crate::config::Config;
1624 use crate::run::{
1625 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
1626 Tally,
1627 };
1628 use std::collections::BTreeMap;
1629 use std::path::PathBuf;
1630 use std::sync::{Mutex, MutexGuard};
1631
1632 static SERIAL: Mutex<()> = Mutex::new(());
1634
1635 fn plain() -> MutexGuard<'static, ()> {
1636 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
1637 set_color(false);
1638 guard
1639 }
1640
1641 fn state() -> RunState {
1642 crate::run::pin_test_home();
1647 let mut s = RunState::new(
1648 PathBuf::from("/repo"),
1649 "main".to_owned(),
1650 "abcdef1234".to_owned(),
1651 "add retries to the uploader".to_owned(),
1652 Config::default(),
1653 );
1654 s.candidates = vec![Candidate {
1655 index: 0,
1656 label: 'A',
1657 agent: "opus".to_owned(),
1658 branch: "magi/x/A".to_owned(),
1659 worktree: PathBuf::from("/wt/A"),
1660 summary: String::new(),
1661 stat: String::new(),
1662 files: 3,
1663 commits: 2,
1664 empty: false,
1665 failed: None,
1666 verified_noop: None,
1667 duration_ms: 42_000,
1668 folded: false,
1669 }];
1670 s.tally = Some(Tally {
1671 first_choice: BTreeMap::from([('A', 3)]),
1672 borda: BTreeMap::new(),
1673 winner: 'A',
1674 rankings: 3,
1675 unanimous_initial: true,
1676 deliberated: false,
1677 changed_votes: 0,
1678 unanimous_final: true,
1679 tie_break: None,
1680 judges: 3,
1681 present: 3,
1682 quorum: 2,
1683 met_quorum: true,
1684 uncontested: None,
1685 });
1686 s
1687 }
1688
1689 #[test]
1690 fn run_report_names_the_winner_and_its_author() {
1691 let _guard = plain();
1692 let text = run(&state());
1693 assert!(text.contains("<- winner"), "{text}");
1694 assert!(text.contains("opus"));
1695 assert!(text.contains("3 files, 2 commits"));
1696 assert!(text.contains("winner A"));
1697 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
1698 }
1699
1700 #[test]
1701 fn a_candidates_own_summary_is_surfaced_not_only_kept_in_run_json() {
1702 let _guard = plain();
1708 let mut s = state();
1709 s.candidates[0].summary =
1710 "investigated 6c5e/8df3: both already merged, see talk 07fe.\nmore detail below."
1711 .to_owned();
1712 let text = run(&s);
1713 assert!(
1714 text.contains("investigated 6c5e/8df3: both already merged, see talk 07fe."),
1715 "{text}"
1716 );
1717 }
1718
1719 #[test]
1720 fn a_verified_noop_run_does_not_read_as_a_failure() {
1721 let _guard = plain();
1725 let mut s = state();
1726 s.status = RunStatus::VerifiedNoop;
1727 s.tally = None;
1728 s.candidates = vec![Candidate {
1729 index: 0,
1730 label: 'A',
1731 agent: "opus".to_owned(),
1732 branch: "magi/x/A".to_owned(),
1733 worktree: PathBuf::from("/wt/A"),
1734 summary: String::new(),
1735 stat: String::new(),
1736 files: 0,
1737 commits: 0,
1738 empty: true,
1739 failed: None,
1740 verified_noop: Some("already fixed by b32cfc4, which is on main".to_owned()),
1741 duration_ms: 9_000,
1742 folded: false,
1743 }];
1744 let text = run(&s);
1745 assert!(
1746 text.contains("agent-verified no-op"),
1747 "the status and the candidate flag must both say so: {text}"
1748 );
1749 assert!(
1750 text.contains("already fixed by b32cfc4"),
1751 "the evidence itself must be readable, not just the verdict: {text}"
1752 );
1753 assert!(
1754 !text.to_lowercase().contains("failed"),
1755 "a verified no-op must never read as the failure it is not: {text}"
1756 );
1757 }
1758
1759 #[test]
1760 fn colour_is_emitted_only_when_enabled() {
1761 let _guard = plain();
1762 set_color(true);
1763 let coloured = run(&state());
1764 set_color(false);
1765 let plain = run(&state());
1766 assert!(coloured.contains('\x1b'));
1767 assert!(!plain.contains('\x1b'));
1768 assert!(coloured.len() > plain.len());
1769 }
1770
1771 #[test]
1772 fn list_line_is_single_line() {
1773 let _guard = plain();
1774 let l = line(&state());
1775 assert_eq!(l.lines().count(), 1);
1776 assert!(l.contains("add retries"));
1777 assert!(l.contains("win A (opus)"));
1778 }
1779
1780 #[test]
1781 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
1782 let _guard = plain();
1783 let mut s = state();
1784 s.tally = Some(Tally {
1785 first_choice: BTreeMap::from([('A', 0)]),
1786 borda: BTreeMap::new(),
1787 winner: 'A',
1788 rankings: 0,
1789 unanimous_initial: false,
1790 deliberated: false,
1791 changed_votes: 0,
1792 unanimous_final: false,
1793 tie_break: None,
1794 judges: 0,
1795 present: 0,
1796 quorum: 0,
1797 met_quorum: true,
1798 uncontested: Some(
1799 "only candidate A produced a usable change; no panel was asked".to_owned(),
1800 ),
1801 });
1802 let text = run(&s);
1803 assert!(
1804 !text.contains("0/3"),
1805 "no panel sat, so the judges line must not read as one that collapsed: {text}"
1806 );
1807 assert!(!text.contains("no usable ranking"), "{text}");
1808 assert!(!text.contains("still split"), "{text}");
1809 assert!(!text.contains("BELOW QUORUM"), "{text}");
1810 assert!(
1811 text.contains("not needed"),
1812 "the report must say judging was skipped, not silent: {text}"
1813 );
1814 assert!(text.contains("winner A"));
1815 }
1816
1817 #[test]
1818 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
1819 let _guard = plain();
1820 let mut s = state();
1821 s.tally = Some(Tally {
1822 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
1823 borda: BTreeMap::new(),
1824 winner: 'A',
1825 rankings: 1,
1826 unanimous_initial: false,
1827 deliberated: false,
1828 changed_votes: 0,
1829 unanimous_final: false,
1830 tie_break: None,
1831 judges: 3,
1832 present: 1,
1833 quorum: 2,
1834 met_quorum: false,
1835 uncontested: None,
1836 });
1837 let text = run(&s);
1838 assert!(text.contains("1/3"), "{text}");
1839 assert!(
1840 text.contains("BELOW QUORUM"),
1841 "a real collapse must still be flagged: {text}"
1842 );
1843 assert!(
1844 !text.contains("not needed"),
1845 "a collapsed panel must not be described as one that was never asked: {text}"
1846 );
1847 }
1848
1849 #[test]
1850 fn a_mode_none_merge_does_not_read_as_landed() {
1851 let _guard = plain();
1852 let mut s = state();
1853 s.merge = Some(MergeOutcome {
1854 mode: crate::config::MergeMode::None,
1855 ok: true,
1856 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1857 empty: false,
1858 });
1859 let text = run(&s);
1860 assert!(
1861 !text.contains(" ok"),
1862 "mode none must not be shown as a landed merge: {text}"
1863 );
1864 assert!(text.contains("not landed"), "{text}");
1865 assert!(
1866 text.contains("branch magi/x/A"),
1867 "the report must say what's left behind: {text}"
1868 );
1869 assert!(
1870 text.contains("rebase"),
1871 "the report must point at the hand-landing steps: {text}"
1872 );
1873 assert!(
1874 !text.contains("placeholder subject"),
1875 "the default merge style is `merge`, which never inherits a \
1876 placeholder subject, so the squash caveat must not appear: {text}"
1877 );
1878 }
1879
1880 #[test]
1881 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
1882 let _guard = plain();
1883 let mut s = state();
1884 s.config.merge.style = MergeStyle::Squash;
1885 s.merge = Some(MergeOutcome {
1886 mode: crate::config::MergeMode::None,
1887 ok: true,
1888 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
1889 retries\""
1890 .to_owned(),
1891 empty: false,
1892 });
1893 let text = run(&s);
1894 assert!(
1895 text.contains("placeholder subject"),
1896 "a squash-style manual merge must warn about the missing message: {text}"
1897 );
1898 assert!(text.contains("--squash"), "{text}");
1899 }
1900
1901 #[test]
1902 fn a_ready_run_left_by_merge_mode_none_does_not_read_as_a_plain_ready() {
1903 let _guard = plain();
1904 let mut s = state();
1905 s.status = RunStatus::Ready;
1906 s.merge = Some(MergeOutcome {
1907 mode: crate::config::MergeMode::None,
1908 ok: true,
1909 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
1910 empty: false,
1911 });
1912
1913 let list = line(&s);
1914 assert!(
1915 !list.contains(" ready "),
1916 "a mode-none run must not read as a plain ready in `magi list`: {list}"
1917 );
1918 assert!(list.contains("no-op by design"), "{list}");
1919
1920 let full = run(&s);
1921 assert!(
1922 !full.contains("magi run") || !full.lines().next().unwrap().contains(" ready"),
1923 "the header line of `magi show` must not say plain ready either: {full}"
1924 );
1925 assert!(full.contains("no-op by design"), "{full}");
1926 }
1927
1928 #[test]
1929 fn an_ordinary_ready_run_still_reads_as_ready() {
1930 let _guard = plain();
1931 let mut s = state();
1932 s.status = RunStatus::Ready;
1933 s.merge = Some(MergeOutcome {
1937 mode: crate::config::MergeMode::Pr,
1938 ok: false,
1939 detail: "https://example.com/pr/1 was closed without merging".to_owned(),
1940 empty: false,
1941 });
1942
1943 let list = line(&s);
1944 assert!(list.contains("ready"), "{list}");
1945 assert!(!list.contains("no-op by design"), "{list}");
1946 }
1947
1948 #[test]
1949 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
1950 let _guard = plain();
1951 let mut s = state();
1952 s.tally = Some(Tally {
1953 first_choice: BTreeMap::from([('A', 0)]),
1954 borda: BTreeMap::new(),
1955 winner: 'A',
1956 rankings: 0,
1957 unanimous_initial: false,
1958 deliberated: false,
1959 changed_votes: 0,
1960 unanimous_final: false,
1961 tie_break: None,
1962 judges: 0,
1963 present: 0,
1964 quorum: 0,
1965 met_quorum: true,
1966 uncontested: Some("only candidate A produced a usable change".to_owned()),
1967 });
1968 let l = line(&s);
1969 assert!(
1970 !l.contains("judges") && !l.contains("quorum"),
1971 "an uncontested run must not carry the same badge a short panel gets: {l}"
1972 );
1973 }
1974
1975 #[test]
1976 fn long_instructions_are_elided() {
1977 let _guard = plain();
1978 let mut s = state();
1979 s.instruction = "x".repeat(200);
1980 assert!(line(&s).contains('…'));
1981 }
1982
1983 #[test]
1984 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1985 let _guard = plain();
1986 let mut lost = state();
1987 lost.reviews = vec![ReviewRound {
1988 round: 1,
1989 head: "abc1234".to_owned(),
1990 verified_head: None,
1991 verified_at: None,
1992 reviews: Vec::new(),
1993 e2e: Vec::new(),
1994 verify_retried: false,
1995 e2e_deferred: false,
1996 e2e_defer_reason: None,
1997 fix: Some(FixRecord {
1998 agent: "opus".to_owned(),
1999 addressed: Vec::new(),
2000 rejected: Vec::new(),
2001 notes: String::new(),
2002 committed: true,
2003 failed: Some("timed out".to_owned()),
2004 duration_ms: 0,
2005 continuation: None,
2006 }),
2007 blocking: 3,
2008 answered: 0,
2009 expected: 0,
2010 clean: false,
2011 progressed: false,
2012 vote_split: false,
2013 reconsideration: Vec::new(),
2014 verdict: None,
2015 }];
2016 let text = run(&lost);
2017 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2018 assert!(
2019 !text.contains("0 addressed"),
2020 "a lost report must never read as `0 addressed`: {text}"
2021 );
2022
2023 let mut rejected_all = state();
2024 rejected_all.reviews = vec![ReviewRound {
2025 round: 1,
2026 head: "abc1234".to_owned(),
2027 verified_head: None,
2028 verified_at: None,
2029 reviews: Vec::new(),
2030 e2e: Vec::new(),
2031 verify_retried: false,
2032 e2e_deferred: false,
2033 e2e_defer_reason: None,
2034 fix: Some(FixRecord {
2035 agent: "opus".to_owned(),
2036 addressed: Vec::new(),
2037 rejected: Vec::new(),
2038 notes: String::new(),
2039 committed: true,
2040 failed: None,
2041 duration_ms: 0,
2042 continuation: None,
2043 }),
2044 blocking: 3,
2045 answered: 0,
2046 expected: 0,
2047 clean: false,
2048 progressed: false,
2049 vote_split: false,
2050 reconsideration: Vec::new(),
2051 verdict: None,
2052 }];
2053 let text2 = run(&rejected_all);
2054 assert!(
2055 text2.contains("0 addressed / 0 rejected"),
2056 "a round the fixer actually reported on keeps the count: {text2}"
2057 );
2058 }
2059
2060 #[test]
2061 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
2062 use crate::run::ReviewRevoteRecord;
2063 use crate::verdict::ReviewVote;
2064
2065 let _guard = plain();
2066 let mut s = state();
2067 s.reviews = vec![ReviewRound {
2068 round: 1,
2069 head: "abc1234".to_owned(),
2070 verified_head: None,
2071 verified_at: None,
2072 reviews: vec![
2073 ReviewRecord {
2074 attempts: 0,
2075 reviewer: 1,
2076 agent: "alpha".to_owned(),
2077 summary: String::new(),
2078 findings: Vec::new(),
2079 vote: Some(ReviewVote::Approve),
2080 failed: None,
2081 duration_ms: 0,
2082 },
2083 ReviewRecord {
2084 attempts: 0,
2085 reviewer: 2,
2086 agent: "beta".to_owned(),
2087 summary: String::new(),
2088 findings: Vec::new(),
2089 vote: Some(ReviewVote::Reject),
2090 failed: None,
2091 duration_ms: 0,
2092 },
2093 ],
2094 e2e: Vec::new(),
2095 verify_retried: false,
2096 e2e_deferred: false,
2097 e2e_defer_reason: None,
2098 fix: None,
2099 blocking: 0,
2100 answered: 2,
2101 expected: 2,
2102 clean: false,
2103 progressed: false,
2104 vote_split: true,
2105 reconsideration: vec![ReviewRevoteRecord {
2106 reviewer: 2,
2107 agent: "beta".to_owned(),
2108 vote: Some(ReviewVote::ApproveWithFindings),
2109 reason: "the other seat's read holds up".to_owned(),
2110 failed: None,
2111 }],
2112 verdict: Some(ReviewVote::ApproveWithFindings),
2113 }];
2114 let text = run(&s);
2115 assert!(text.contains("review-1 vote"), "{text}");
2116 assert!(text.contains("review-2 vote"), "{text}");
2117 assert!(text.contains("panel split"), "{text}");
2118 assert!(text.contains("reconsideration"), "{text}");
2119 assert!(text.contains("the other seat's read holds up"), "{text}");
2120 }
2121
2122 #[test]
2123 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
2124 let _guard = plain();
2130 let mut s = state();
2131 s.reviews = vec![ReviewRound {
2132 round: 1,
2133 head: "abc1234".to_owned(),
2134 verified_head: None,
2135 verified_at: None,
2136 reviews: vec![
2137 ReviewRecord {
2138 attempts: 0,
2139 reviewer: 1,
2140 agent: "alpha".to_owned(),
2141 summary: String::new(),
2142 findings: Vec::new(),
2143 vote: None,
2144 failed: None,
2145 duration_ms: 0,
2146 },
2147 ReviewRecord {
2148 attempts: 0,
2149 reviewer: 2,
2150 agent: "beta".to_owned(),
2151 summary: String::new(),
2152 findings: Vec::new(),
2153 vote: None,
2154 failed: Some("agent timed out".to_owned()),
2155 duration_ms: 0,
2156 },
2157 ],
2158 e2e: Vec::new(),
2159 verify_retried: false,
2160 e2e_deferred: false,
2161 e2e_defer_reason: None,
2162 fix: Some(FixRecord {
2163 agent: "opus".to_owned(),
2164 addressed: Vec::new(),
2165 rejected: Vec::new(),
2166 notes: String::new(),
2167 committed: true,
2168 failed: Some("timed out".to_owned()),
2169 duration_ms: 0,
2170 continuation: None,
2171 }),
2172 blocking: 0,
2173 answered: 1,
2174 expected: 2,
2175 clean: false,
2176 progressed: true,
2177 vote_split: false,
2178 reconsideration: Vec::new(),
2179 verdict: None,
2180 }];
2181 let text = run(&s);
2182 assert!(text.contains("incomplete"), "{text}");
2183 assert!(text.contains("1/2 reviewers answered"), "{text}");
2184 assert!(text.contains("review-2: agent timed out"), "{text}");
2185 assert!(text.contains("adoption report lost (timed out)"), "{text}");
2186 assert!(
2187 !text.contains("clean"),
2188 "a round missing half its panel must never render as clean: {text}"
2189 );
2190 }
2191
2192 #[test]
2193 fn a_build_failure_is_not_reported_as_a_test_failure() {
2194 let _guard = plain();
2195 let mut s = state();
2196 s.reviews = vec![ReviewRound {
2197 round: 1,
2198 head: "abc1234".to_owned(),
2199 verified_head: None,
2200 verified_at: None,
2201 reviews: Vec::new(),
2202 e2e: vec![CommandOutcome {
2203 command: "cargo test".to_owned(),
2204 code: Some(1),
2205 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
2206 duration_ms: 100,
2207 resource_blocked: false,
2208 }],
2209 verify_retried: true,
2210 e2e_deferred: false,
2211 e2e_defer_reason: None,
2212 fix: None,
2213 blocking: 0,
2214 answered: 0,
2215 expected: 0,
2216 clean: false,
2217 progressed: false,
2218 vote_split: false,
2219 reconsideration: Vec::new(),
2220 verdict: None,
2221 }];
2222 let text = run(&s);
2223 assert!(text.contains("could not run"), "{text}");
2224 assert!(text.contains("retried once"), "{text}");
2225 assert!(!text.contains("e2e RED"), "{text}");
2226 }
2227
2228 #[test]
2229 fn a_resource_blocked_e2e_never_reads_as_red_or_as_a_build_failure() {
2230 let _guard = plain();
2231 let mut s = state();
2232 s.reviews = vec![ReviewRound {
2233 round: 1,
2234 head: "abc1234".to_owned(),
2235 verified_head: None,
2236 verified_at: None,
2237 reviews: Vec::new(),
2238 e2e: vec![CommandOutcome {
2239 command: "(waiting for the shared build cache)".to_owned(),
2240 code: None,
2241 output_tail: "held by run x node e2e seat e2e".to_owned(),
2242 duration_ms: 100,
2243 resource_blocked: true,
2244 }],
2245 verify_retried: false,
2246 e2e_deferred: false,
2247 e2e_defer_reason: None,
2248 fix: None,
2249 blocking: 0,
2250 answered: 0,
2251 expected: 0,
2252 clean: false,
2253 progressed: false,
2254 vote_split: false,
2255 reconsideration: Vec::new(),
2256 verdict: None,
2257 }];
2258 let text = run(&s);
2259 assert!(text.contains("shared build cache unavailable"), "{text}");
2260 assert!(!text.contains("e2e RED"), "{text}");
2261 assert!(!text.contains("build/link failure"), "{text}");
2262 }
2263
2264 #[test]
2265 fn a_round_with_more_than_one_e2e_command_names_each_one() {
2266 let _guard = plain();
2271 let mut s = state();
2272 s.reviews = vec![ReviewRound {
2273 round: 1,
2274 head: "abc1234".to_owned(),
2275 verified_head: Some("abc1234".to_owned()),
2276 verified_at: Some(jiff::Timestamp::now()),
2277 reviews: Vec::new(),
2278 e2e: vec![
2279 CommandOutcome {
2280 command: "cargo test --locked --all-targets".to_owned(),
2281 code: Some(0),
2282 output_tail: String::new(),
2283 duration_ms: 0,
2284 resource_blocked: false,
2285 },
2286 CommandOutcome {
2287 command: "cargo make check".to_owned(),
2288 code: Some(1),
2289 output_tail: "clippy: unused import".to_owned(),
2290 duration_ms: 0,
2291 resource_blocked: false,
2292 },
2293 ],
2294 verify_retried: false,
2295 e2e_deferred: false,
2296 e2e_defer_reason: None,
2297 fix: None,
2298 blocking: 0,
2299 answered: 0,
2300 expected: 0,
2301 clean: false,
2302 progressed: false,
2303 vote_split: false,
2304 reconsideration: Vec::new(),
2305 verdict: None,
2306 }];
2307 let text = run(&s);
2308 assert!(text.contains("cargo test --locked --all-targets"), "{text}");
2309 assert!(text.contains("cargo make check"), "{text}");
2310 assert!(text.contains("clippy: unused import"), "{text}");
2311 }
2312
2313 #[test]
2314 fn a_declined_finding_shows_its_reason() {
2315 use crate::verdict::{Finding, Rejection, Severity};
2316
2317 let _guard = plain();
2318 let mut s = state();
2319 s.status = RunStatus::Ready;
2320 s.reviews = vec![ReviewRound {
2321 round: 1,
2322 head: "deadbee".to_owned(),
2323 verified_head: None,
2324 verified_at: None,
2325 reviews: vec![ReviewRecord {
2326 attempts: 0,
2327 reviewer: 1,
2328 agent: "alpha".to_owned(),
2329 summary: String::new(),
2330 findings: vec![Finding {
2331 id: "R1-1-1".to_owned(),
2332 severity: Severity::Major,
2333 file: None,
2334 line: None,
2335 title: "still open".to_owned(),
2336 detail: String::new(),
2337 }],
2338 vote: None,
2339 failed: None,
2340 duration_ms: 0,
2341 }],
2342 e2e: vec![CommandOutcome {
2343 command: "cargo test".to_owned(),
2344 code: Some(0),
2345 output_tail: String::new(),
2346 duration_ms: 0,
2347 resource_blocked: false,
2348 }],
2349 verify_retried: false,
2350 e2e_deferred: false,
2351 e2e_defer_reason: None,
2352 fix: Some(FixRecord {
2353 agent: "alpha".to_owned(),
2354 addressed: Vec::new(),
2355 rejected: vec![Rejection {
2356 id: "R1-1-2".to_owned(),
2357 why: "cannot be triggered from any caller".to_owned(),
2358 }],
2359 notes: String::new(),
2360 committed: true,
2361 failed: None,
2362 duration_ms: 0,
2363 continuation: None,
2364 }),
2365 blocking: 1,
2366 answered: 1,
2367 expected: 1,
2368 clean: false,
2369 progressed: true,
2370 vote_split: false,
2371 reconsideration: Vec::new(),
2372 verdict: None,
2373 }];
2374
2375 let text = run(&s);
2376 assert!(text.contains("R1-1-2"), "{text}");
2377 assert!(text.contains("cannot be triggered"), "{text}");
2378 assert!(text.contains("still open"), "{text}");
2379 assert!(
2380 text.contains("handed off"),
2381 "a mergeable run with an open round must say so: {text}"
2382 );
2383 }
2384
2385 #[test]
2386 fn a_failing_gate_command_shows_its_output() {
2387 let _guard = plain();
2388 let mut s = state();
2389 s.status = RunStatus::Blocked;
2390 s.gate = vec![CommandOutcome {
2391 command: "cargo make check".to_owned(),
2392 code: Some(101),
2393 output_tail: "error[E0308]: mismatched types".to_owned(),
2394 duration_ms: 0,
2395 resource_blocked: false,
2396 }];
2397 s.gate_ran = true;
2398
2399 let text = run(&s);
2400 assert!(text.contains("mismatched types"), "{text}");
2401 }
2402
2403 #[test]
2404 fn a_gate_with_no_commands_configured_shows_a_pass_not_silence() {
2405 let _guard = plain();
2406 let mut s = state();
2407 s.status = RunStatus::Ready;
2408 s.gate_ran = true;
2409 assert!(s.gate.is_empty());
2410
2411 let text = run(&s);
2412 assert!(
2413 text.contains("gate") && text.contains("no gate commands configured"),
2414 "a run gated on nothing must say so, not read as if the gate never ran: {text}"
2415 );
2416 }
2417
2418 #[test]
2419 fn a_gate_that_has_not_run_yet_shows_nothing() {
2420 let _guard = plain();
2421 let s = state();
2422 assert!(!s.gate_ran);
2423 assert!(s.gate.is_empty());
2424
2425 let text = run(&s);
2426 assert!(
2427 !text.contains("no gate commands configured"),
2428 "an unattempted gate must not be shown as a pass: {text}"
2429 );
2430 }
2431
2432 #[test]
2433 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
2434 let _guard = plain();
2435 let mut s = state();
2436 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
2437 let text = active_seats(&s, Liveness::Live);
2438 assert!(text.contains("running now"));
2439 assert!(text.contains("judge-2"));
2440 assert!(text.contains("judge"));
2441 assert!(!text.contains("no live daemon"), "{text}");
2442 assert!(!text.contains("could not be confirmed"), "{text}");
2443 }
2444
2445 #[test]
2446 fn active_seats_flags_a_leftover_from_a_dead_process() {
2447 let _guard = plain();
2448 let mut s = state();
2449 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
2450 let text = active_seats(&s, Liveness::Dead);
2451 assert!(
2452 text.contains("no live daemon"),
2453 "a stale entry must not read as running: {text}"
2454 );
2455 }
2456
2457 #[test]
2458 fn superseded_notice_names_the_run_that_replaced_it() {
2459 let _guard = plain();
2460 let text = superseded_notice(Some("bbbb"));
2461 assert!(text.contains("SUPERSEDED"), "{text}");
2462 assert!(text.contains("bbbb"), "{text}");
2463 }
2464
2465 #[test]
2466 fn superseded_notice_is_silent_for_the_latest_attempt() {
2467 let _guard = plain();
2468 assert_eq!(superseded_notice(None), "");
2469 }
2470
2471 #[test]
2472 fn list_line_marks_a_nonterminal_run_with_a_dead_driver_stale() {
2473 let _guard = plain();
2474 let mut s = state();
2475 s.status = RunStatus::Reviewing;
2476 let text = line_with_liveness(&s, Liveness::Dead);
2477 assert!(text.contains("STALE"), "{text}");
2478 assert!(text.contains("resume required"), "{text}");
2479 assert!(liveness_notice(&s, Liveness::Dead).contains("STALE"));
2480 }
2481
2482 #[test]
2487 fn active_seats_reports_uncertainty_without_claiming_death() {
2488 let _guard = plain();
2489 let mut s = state();
2490 s.seat_started("review", "review-1", std::time::Duration::from_secs(60), 0);
2491 let text = active_seats(&s, Liveness::Unknown);
2492 assert!(
2493 text.contains("could not be confirmed"),
2494 "an unproven state must read as uncertain, not dead: {text}"
2495 );
2496 assert!(!text.contains("no live daemon"), "{text}");
2497 }
2498
2499 #[test]
2500 fn active_seats_is_empty_when_nothing_is_running() {
2501 let _guard = plain();
2502 assert_eq!(active_seats(&state(), Liveness::Live), "");
2503 }
2504
2505 #[test]
2511 fn active_seats_shows_a_running_verify_task_and_its_command() {
2512 let _guard = plain();
2513 let mut s = state();
2514 s.task_command(
2515 "e2e",
2516 "verify",
2517 0,
2518 "cargo test",
2519 2,
2520 3,
2521 std::time::Duration::from_secs(600),
2522 );
2523 let text = active_seats(&s, Liveness::Live);
2524 assert!(text.contains("e2e"), "{text}");
2525 assert!(text.contains("(2/3)"), "{text}");
2526 assert!(text.contains("cargo test"), "{text}");
2527 }
2528
2529 #[test]
2530 fn no_jobs_section_appears_when_nothing_was_ever_collected() {
2531 let _guard = plain();
2532 assert!(!run(&state()).contains("background jobs"));
2535 }
2536
2537 #[test]
2538 fn a_codex_roster_with_no_completed_jobs_yet_says_so_instead_of_staying_silent() {
2539 let _guard = plain();
2540 let mut s = state();
2541 s.config.agents.push(crate::config::AgentSpec {
2542 id: "codex-one".to_owned(),
2543 kind: crate::config::AgentKind::Codex,
2544 model: None,
2545 command: vec!["codex".to_owned()],
2546 extra_args: Vec::new(),
2547 env: BTreeMap::new(),
2548 prompt_delivery: None,
2549 });
2550 let text = run(&s);
2551 assert!(
2552 text.contains("background jobs"),
2553 "a run that could report this must not read the same as one that never could: \
2554 {text}"
2555 );
2556 assert!(text.contains("no completed command evidence yet"));
2557 }
2558
2559 #[test]
2560 fn recovered_running_and_unreadable_jobs_are_told_apart() {
2561 let _guard = plain();
2562 let mut s = state();
2563 s.jobs = vec![
2564 crate::run::JobRecord {
2565 node: "implement".to_owned(),
2566 round: None,
2567 seat: "impl-A".to_owned(),
2568 id: "item49".to_owned(),
2569 description: "cargo test --test graph_cached_gate".to_owned(),
2570 checked_at: jiff::Timestamp::now(),
2571 status: crate::run::JobStatus::Completed,
2572 exit_code: Some(0),
2573 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2574 source: "codex".to_owned(),
2575 },
2576 crate::run::JobRecord {
2577 node: "fix".to_owned(),
2578 round: None,
2579 seat: "impl-A".to_owned(),
2580 id: "item52".to_owned(),
2581 description: "cargo test --test graph_split".to_owned(),
2582 checked_at: jiff::Timestamp::now(),
2583 status: crate::run::JobStatus::Failed,
2584 exit_code: Some(101),
2585 result_summary: "test result: 1 passed; 1 failed".to_owned(),
2586 source: "codex".to_owned(),
2587 },
2588 crate::run::JobRecord {
2589 node: "fix".to_owned(),
2590 round: None,
2591 seat: "impl-A".to_owned(),
2592 id: "item60".to_owned(),
2593 description: "cargo build".to_owned(),
2594 checked_at: jiff::Timestamp::now(),
2595 status: crate::run::JobStatus::Unknown,
2596 exit_code: None,
2597 result_summary: String::new(),
2598 source: "codex".to_owned(),
2599 },
2600 ];
2601 let text = run(&s);
2602 assert!(text.contains("background jobs"));
2603 assert!(text.contains("item49"));
2604 assert!(text.contains("item52"));
2605 assert!(text.contains("item60"));
2606 assert!(text.contains("completed"));
2610 assert!(text.contains("failed"));
2611 assert!(text.contains("unknown"));
2612 assert!(text.contains("adapter coverage"));
2614 }
2615
2616 #[test]
2617 fn a_jobs_own_round_is_shown_when_known() {
2618 let _guard = plain();
2619 let mut s = state();
2620 s.jobs = vec![crate::run::JobRecord {
2621 node: "review".to_owned(),
2622 round: Some(2),
2623 seat: "review-1".to_owned(),
2624 id: "item9".to_owned(),
2625 description: "cargo test --test graph_cached_gate".to_owned(),
2626 checked_at: jiff::Timestamp::now(),
2627 status: crate::run::JobStatus::Completed,
2628 exit_code: Some(0),
2629 result_summary: "test result: 2 passed; 0 failed".to_owned(),
2630 source: "codex".to_owned(),
2631 }];
2632 let text = run(&s);
2633 assert!(
2634 text.contains("round 2"),
2635 "the round this seat's own command ran in must be visible, distinct from magi's \
2636 own recorded verify: {text}"
2637 );
2638 }
2639
2640 #[test]
2641 fn stats_table_renders_without_runs() {
2642 let _guard = plain();
2643 let text = stats(&Stats::default());
2644 assert!(text.contains("0 total"));
2645 assert!(!text.contains("implementation"));
2646 assert!(!text.contains("node durations"));
2647 }
2648
2649 #[test]
2650 fn repo_summary_lists_run_count_and_completion_rate() {
2651 let _guard = plain();
2652 let a = crate::stats::RepoStats {
2653 repo: PathBuf::from("/repos/a"),
2654 name: "a".to_owned(),
2655 stats: Stats {
2656 totals: crate::stats::Totals {
2657 runs: 4,
2658 merged: 2,
2659 ready: 1,
2660 ..Default::default()
2661 },
2662 ..Default::default()
2663 },
2664 };
2665 let text = repo_summary(&[a]);
2666 assert!(text.contains("repos"), "{text}");
2667 assert!(text.contains("a 4 runs 75% completion"), "{text}");
2668 }
2669
2670 #[test]
2671 fn repo_summary_disambiguates_colliding_names_with_the_parent_directory() {
2672 let _guard = plain();
2673 let a = crate::stats::RepoStats {
2674 repo: PathBuf::from("/roots/one/magi"),
2675 name: "magi".to_owned(),
2676 stats: Stats::default(),
2677 };
2678 let b = crate::stats::RepoStats {
2679 repo: PathBuf::from("/roots/two/magi"),
2680 name: "magi".to_owned(),
2681 stats: Stats::default(),
2682 };
2683 let text = repo_summary(&[a, b]);
2684 assert!(text.contains("magi (one)"), "{text}");
2685 assert!(text.contains("magi (two)"), "{text}");
2686 }
2687
2688 #[test]
2689 fn stats_table_renders_advisor_reflection() {
2690 let _guard = plain();
2691 let st = Stats {
2692 advisors: vec![crate::stats::AdvisorStats {
2693 agent: "alpha".to_owned(),
2694 seated: 3,
2695 proposed: 2,
2696 absent: 1,
2697 faint: 1,
2698 strong: 1,
2699 }],
2700 ..Default::default()
2701 };
2702 let text = stats(&st);
2703 assert!(text.contains("design deliberation"));
2704 assert!(
2705 text.contains("approximate"),
2706 "must note the heuristic: {text}"
2707 );
2708 assert!(text.contains("alpha"));
2709 assert!(text.contains("50%"));
2710 }
2711
2712 #[test]
2713 fn stats_table_shows_release_bump_coverage_even_when_nothing_was_recorded() {
2714 let _guard = plain();
2719 let st = Stats {
2720 release_bumps: crate::stats::ReleaseBumpStats {
2721 merged: 3,
2722 ..Default::default()
2723 },
2724 ..Default::default()
2725 };
2726 let text = stats(&st);
2727 assert!(text.contains("release bumps"), "{text}");
2728 assert!(
2729 text.contains("0 of 3 merged run(s) recorded a bump"),
2730 "{text}"
2731 );
2732 }
2733
2734 #[test]
2735 fn stats_table_renders_release_bump_coverage_and_attention() {
2736 let _guard = plain();
2737 let st = Stats {
2738 release_bumps: crate::stats::ReleaseBumpStats {
2739 merged: 4,
2740 recorded: 2,
2741 pr_opened: 2,
2742 automerge_enabled: 1,
2743 merged_directly: 0,
2744 needs_attention: 1,
2745 },
2746 ..Default::default()
2747 };
2748 let text = stats(&st);
2749 assert!(text.contains("release bumps"), "{text}");
2750 assert!(
2751 text.contains("2 of 4 merged run(s) recorded a bump (50%)"),
2752 "{text}"
2753 );
2754 assert!(text.contains("1 clean, 1 needed a human (50%)"), "{text}");
2755 }
2756
2757 #[test]
2758 fn stats_table_renders_node_durations_with_a_bar_and_unmeasured_count() {
2759 let _guard = plain();
2760 let st = Stats {
2761 nodes: vec![
2762 crate::stats::NodeDuration {
2763 node: "implement".to_owned(),
2764 runs: 2,
2765 total_secs: 200,
2766 max_secs: 150,
2767 single: 1,
2768 },
2769 crate::stats::NodeDuration {
2770 node: "gate".to_owned(),
2771 runs: 0,
2772 total_secs: 0,
2773 max_secs: 0,
2774 single: 3,
2775 },
2776 ],
2777 ..Default::default()
2778 };
2779 let text = stats(&st);
2780 assert!(text.contains("node durations"));
2781 assert!(text.contains("implement"));
2782 assert!(text.contains("2 runs"));
2783 assert!(text.contains('█'), "the leader must draw a bar: {text}");
2784 assert!(text.contains("1 unmeasured"));
2785 assert!(text.contains("gate"));
2786 assert!(text.contains("3 unmeasured"));
2787 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
2788 }
2789
2790 #[test]
2791 fn implementation_rates_use_independent_low_n_boundaries() {
2792 let _guard = plain();
2793 for (competitive_n, solo_n) in [(9, 10), (10, 9)] {
2794 let text = stats(&Stats {
2795 agents: vec![crate::stats::AgentStats {
2796 agent: "alpha".to_owned(),
2797 competitive_entered: competitive_n,
2798 competitive_wins: 1,
2799 solo_entered: solo_n,
2800 solo_adopted: 2,
2801 ..Default::default()
2802 }],
2803 ..Default::default()
2804 });
2805 let (competitive, solo) = text
2806 .split_once("implementation (solo runs, completion/adoption rate)")
2807 .unwrap();
2808 for (section, n, numerator) in [(competitive, competitive_n, 1), (solo, solo_n, 2)] {
2809 let row = section.lines().find(|line| line.contains("alpha")).unwrap();
2810 let cells: Vec<_> = row.split_whitespace().collect();
2811 assert_eq!(cells[1], numerator.to_string());
2812 assert_eq!(cells[2], n.to_string());
2813 assert_eq!(row.contains("low n"), n < 10);
2814 }
2815 }
2816 }
2817
2818 #[test]
2819 fn stats_table_renders_competitive_and_solo_tables_with_low_n() {
2820 let _guard = plain();
2821 let st = Stats {
2822 agents: vec![
2823 crate::stats::AgentStats {
2824 agent: "alpha".to_owned(),
2825 entered: 15,
2826 wins: 10,
2827 empty: 0,
2828 competitive_entered: 12,
2829 competitive_wins: 9,
2830 solo_entered: 3,
2831 solo_adopted: 1,
2832 },
2833 crate::stats::AgentStats {
2834 agent: "beta".to_owned(),
2835 entered: 5,
2836 wins: 2,
2837 empty: 1,
2838 competitive_entered: 4,
2839 competitive_wins: 2,
2840 solo_entered: 1,
2841 solo_adopted: 0,
2842 },
2843 ],
2844 ..Default::default()
2845 };
2846 let text = stats(&st);
2847 assert!(text.contains("implementation (competitive runs, 2+ candidates)"));
2848 assert!(text.contains("alpha"));
2849 assert!(text.contains("75%"));
2850 assert!(text.contains("beta"));
2851 assert!(text.contains("50%"));
2852 assert!(text.contains("implementation (solo runs, completion/adoption rate)"));
2853 assert!(text.contains("33%"));
2854 assert!(text.contains("low n"));
2855 }
2856}