1use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::{MergeMode, MergeStyle};
16use crate::run::{CommandOutcome, RunState, RunStatus, tail};
17use crate::stats::Stats;
18use crate::verdict::ReviewVote;
19
20static COLOR: AtomicBool = AtomicBool::new(true);
21
22pub fn set_color(on: bool) {
24 COLOR.store(on, Ordering::Relaxed);
25}
26
27fn paint(text: &str, code: &str) -> String {
28 if COLOR.load(Ordering::Relaxed) {
29 format!("\x1b[{code}m{text}\x1b[0m")
30 } else {
31 text.to_owned()
32 }
33}
34
35fn bold(t: &str) -> String {
36 paint(t, "1")
37}
38fn dim(t: &str) -> String {
39 paint(t, "2")
40}
41fn red(t: &str) -> String {
42 paint(t, "31")
43}
44fn green(t: &str) -> String {
45 paint(t, "32")
46}
47fn yellow(t: &str) -> String {
48 paint(t, "33")
49}
50fn cyan(t: &str) -> String {
51 paint(t, "36")
52}
53
54fn status_word(status: RunStatus) -> String {
59 let text = format!("{status:?}").to_lowercase();
60 match status {
61 RunStatus::Merged => bold(&green(&text)),
62 RunStatus::Ready => green(&text),
63 RunStatus::Stalled => bold(&yellow(&text)),
64 RunStatus::Blocked => yellow(&text),
65 RunStatus::Failed => red(&text),
66 _ => cyan(&text),
67 }
68}
69
70fn vote_tag(vote: ReviewVote) -> String {
74 let text = vote.label();
75 match vote {
76 ReviewVote::Approve => green(text),
77 ReviewVote::ApproveWithFindings => yellow(text),
78 ReviewVote::Reject => red(text),
79 }
80}
81
82pub fn line(state: &RunState) -> String {
84 let winner = state
85 .tally
86 .as_ref()
87 .map_or("-".to_owned(), |t| t.winner.to_string());
88 let agent = state.winner().map_or("-", |c| c.agent.as_str());
89 let quorum = match state.tally.as_ref() {
92 Some(t) if !t.met_quorum => format!(
93 " {}",
94 bold(&red(&format!("quorum {}/{}", t.present, t.judges)))
95 ),
96 Some(t) if t.present > 0 && t.present < t.judges => format!(
97 " {}",
98 yellow(&format!("judges {}/{}", t.present, t.judges))
99 ),
100 _ => String::new(),
101 };
102 format!(
103 "{} {:<20} {:>2}c {:>2}j win {} ({}){quorum} {}",
104 dim(&state.id),
105 status_word(state.status),
106 state.candidates.len(),
107 state.judgements.len(),
108 winner,
109 agent,
110 first_line(&state.instruction)
111 )
112}
113
114fn first_line(text: &str) -> String {
115 let line = text.lines().next().unwrap_or_default();
116 if line.chars().count() > 68 {
117 format!("{}…", line.chars().take(67).collect::<String>())
118 } else {
119 line.to_owned()
120 }
121}
122
123fn short(commit: &str) -> String {
124 commit.chars().take(7).collect()
125}
126
127pub fn run(state: &RunState) -> String {
129 let mut s = String::new();
130 let _ = writeln!(
131 s,
132 "{} {} {}",
133 bold("magi run"),
134 bold(&state.id),
135 status_word(state.status)
136 );
137 let _ = writeln!(
138 s,
139 " repo {} ({} @ {})",
140 state.repo.display(),
141 state.base_branch,
142 short(&state.base_commit)
143 );
144 let _ = writeln!(s, " created {}", state.created_local());
145 let _ = writeln!(s, " task {}", first_line(&state.instruction));
146 let _ = writeln!(s, " state {}", state.dir().display());
147
148 let _ = writeln!(s, "\n{}", bold("candidates"));
149 for c in &state.candidates {
150 let flag = match (&c.failed, c.empty) {
151 (Some(e), _) => red(&format!("failed: {e}")),
152 (None, true) => yellow("no change"),
153 _ => format!("{} files, {} commits", c.files, c.commits),
154 };
155 let crown = if state.tally.as_ref().is_some_and(|t| t.winner == c.label) {
156 bold(&green(" <- winner"))
157 } else {
158 String::new()
159 };
160 let _ = writeln!(
161 s,
162 " {} {:<12} {:<30} {:>5}s{}",
163 bold(&c.label.to_string()),
164 c.agent,
165 flag,
166 c.duration_ms / 1000,
167 crown
168 );
169 }
170
171 if !state.judgements.is_empty() {
172 let _ = writeln!(s, "\n{}", bold("blind judging"));
173 for j in &state.judgements {
174 match &j.failed {
175 Some(e) => {
176 let _ = writeln!(
177 s,
178 " judge {} {}",
179 j.judge,
180 red(&format!("no ranking: {e}"))
181 );
182 }
183 None => {
184 let _ = writeln!(
185 s,
186 " judge {} {:<12} {} confidence {}",
187 j.judge,
188 j.agent,
189 bold(&j.ranking.iter().collect::<String>()),
190 j.confidence.map_or("-".to_owned(), |c| c.to_string())
191 );
192 }
193 }
194 }
195 }
196
197 if let Some(t) = &state.tally {
198 if t.deliberated {
199 let _ = writeln!(s, "\n{}", bold("deliberation"));
200 for round in &state.deliberation {
201 for turn in &round.turns {
202 let _ = writeln!(
203 s,
204 " r{} judge {} -> {}",
205 round.round,
206 turn.judge,
207 turn.tentative.map_or("-".to_owned(), |c| c.to_string())
208 );
209 }
210 }
211 }
212
213 if !state.votes.is_empty() {
214 let _ = writeln!(s, "\n{}", bold("final votes (collected privately)"));
215 for v in &state.votes {
216 let _ = writeln!(
217 s,
218 " judge {} {:<12} {}{}",
219 v.judge,
220 v.agent,
221 bold(&v.vote.unwrap_or('?').to_string()),
222 if v.changed {
223 yellow(" (changed after deliberation)")
224 } else {
225 String::new()
226 }
227 );
228 }
229 }
230
231 let _ = writeln!(s, "\n{}", bold("tally"));
232 match &t.uncontested {
238 Some(reason) => {
239 let _ = writeln!(
240 s,
241 " judging {}",
242 cyan(&format!("not needed — {reason}"))
243 );
244 }
245 None => {
246 let _ = writeln!(
247 s,
248 " judges {} present{}",
249 if t.met_quorum {
250 green(&format!("{}/{}", t.present, t.judges))
251 } else {
252 red(&format!("{}/{}", t.present, t.judges))
253 },
254 if t.quorum > 0 {
255 format!(" ({quorum} required)", quorum = t.quorum)
256 } else {
257 String::new()
258 }
259 );
260 if !t.met_quorum {
261 let _ = writeln!(
262 s,
263 " {}",
264 bold(&red("BELOW QUORUM — verdict is not trustworthy"))
265 );
266 }
267 let _ = writeln!(
268 s,
269 " first choice {}",
270 t.first_choice
271 .iter()
272 .map(|(k, v)| format!("{k}:{v}"))
273 .collect::<Vec<_>>()
274 .join(" ")
275 );
276 let _ = writeln!(
277 s,
278 " initial {}",
279 match (t.rankings, t.unanimous_initial) {
280 (0, _) => red("no usable ranking"),
281 (1, _) => yellow("one usable ranking - not a consensus"),
282 (_, true) => green("unanimous"),
283 (_, false) => yellow("split"),
284 }
285 );
286 let _ = writeln!(
287 s,
288 " after votes {} ({} judge(s) moved)",
289 if t.unanimous_final {
290 green("unanimous")
291 } else {
292 yellow("still split")
293 },
294 t.changed_votes
295 );
296 if let Some(tb) = &t.tie_break {
297 let _ = writeln!(s, " tie break {tb}");
298 }
299 }
300 }
301 if !state.quota.is_empty() {
302 let _ = writeln!(
303 s,
304 " rate limited {}",
305 state
306 .quota
307 .iter()
308 .map(|q| q.seat.as_str())
309 .collect::<Vec<_>>()
310 .join(", ")
311 );
312 }
313 let _ = writeln!(s, " winner {}", bold(&green(&t.winner.to_string())));
314 }
315
316 if !state.reviews.is_empty() {
317 let _ = writeln!(s, "\n{}", bold("review + verification"));
318 for r in &state.reviews {
319 let raised: usize = r.reviews.iter().map(|x| x.findings.len()).sum();
320 let e2e = if r.e2e.is_empty() {
324 dim("no e2e")
325 } else if r.e2e.iter().all(|o| o.ok()) {
326 green("e2e green")
327 } else if r.e2e.iter().any(CommandOutcome::build_failed) {
328 yellow("e2e could not run (build/link failure)")
329 } else {
330 red("e2e RED")
331 };
332 let e2e = if r.verify_retried {
333 format!("{e2e}, retried once")
334 } else {
335 e2e
336 };
337 let status = if r.incomplete() {
341 yellow("incomplete")
342 } else if r.clean {
343 green("clean")
344 } else {
345 yellow("open")
346 };
347 let panel = if r.incomplete() {
351 let missing: Vec<String> = r
352 .reviews
353 .iter()
354 .filter_map(|x| {
355 x.failed
356 .as_ref()
357 .map(|why| format!("review-{}: {why}", x.reviewer))
358 })
359 .collect();
360 format!(
361 " {}/{} reviewers answered ({})",
362 r.answered,
363 r.expected,
364 missing.join(", ")
365 )
366 } else {
367 String::new()
368 };
369 let verdict = r.verdict.map_or(String::new(), |v| {
375 format!(
376 ", verdict {}{}",
377 vote_tag(v),
378 if r.vote_split { " (panel split)" } else { "" }
379 )
380 });
381 let _ = writeln!(
382 s,
383 " round {} {} @ {}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
384 r.round,
385 status,
386 short(&r.head),
387 r.blocking,
388 r.fix.as_ref().map_or(String::new(), |f| {
389 let tree = if r.progressed {
390 green("changed")
391 } else {
392 yellow("unchanged")
393 };
394 match &f.failed {
395 Some(reason) => format!(
400 " fix: {}, tree {tree}{}",
401 yellow(&format!("adoption report lost ({reason})")),
402 if f.committed {
403 String::new()
404 } else {
405 red(" (NO COMMIT)")
406 }
407 ),
408 None => format!(
409 " fix: {} addressed / {} rejected, tree {tree}{}",
410 f.addressed.len(),
411 f.rejected.len(),
412 if f.committed {
413 String::new()
414 } else {
415 red(" (NO COMMIT)")
416 }
417 ),
418 }
419 })
420 );
421 for rec in &r.reviews {
422 if let Some(vote) = rec.vote {
423 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
424 }
425 for f in &rec.findings {
426 let adopted = r
427 .fix
428 .as_ref()
429 .is_some_and(|fix| fix.addressed.contains(&f.id));
430 let _ = writeln!(
431 s,
432 " {} [{:?}] {}{}",
433 dim(&f.id),
434 f.severity,
435 f.title,
436 if adopted {
437 green(" fixed")
438 } else {
439 String::new()
440 }
441 );
442 }
443 }
444 if let Some(fix) = &r.fix {
445 for rej in &fix.rejected {
446 let _ = writeln!(
447 s,
448 " {} {}: {}",
449 dim(&rej.id),
450 yellow("declined"),
451 rej.why
452 );
453 }
454 }
455 if !r.reconsideration.is_empty() {
459 let _ = writeln!(s, " {}", dim("reconsideration:"));
460 for rv in &r.reconsideration {
461 match rv.vote {
462 Some(v) => {
463 let _ = writeln!(
464 s,
465 " review-{} -> {} {}",
466 rv.reviewer,
467 vote_tag(v),
468 rv.reason
469 );
470 }
471 None => {
472 let _ = writeln!(
473 s,
474 " review-{} -> {}",
475 rv.reviewer,
476 red(&format!(
477 "no revote ({})",
478 rv.failed.as_deref().unwrap_or("unknown")
479 ))
480 );
481 }
482 }
483 }
484 }
485 }
486 if state.handed_off_with_open_findings() {
487 let _ = writeln!(
488 s,
489 "\n {}",
490 yellow(&format!(
491 "handed off with {} finding(s) still open — gate and e2e were green; \
492 see above for what a person should still look at",
493 state.open_findings().len()
494 ))
495 );
496 }
497 }
498
499 if let Some(bs) = &state.base_sync {
500 let _ = writeln!(s, "\n{}", bold("base sync"));
501 let status = if let Some(c) = &bs.conflict {
502 red(&format!("conflict: {}", first_line(c)))
503 } else if bs.behind == 0 {
504 green("in sync")
505 } else {
506 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
507 };
508 let _ = writeln!(
509 s,
510 " {} @ {} {status}{}",
511 state.base_branch,
512 short(&bs.tip),
513 if bs.attempts > 0 {
514 format!(" ({} rebase attempt(s))", bs.attempts)
515 } else {
516 String::new()
517 }
518 );
519 }
520
521 if !state.gate.is_empty() {
522 let _ = writeln!(s, "\n{}", bold("gate"));
523 for o in &state.gate {
524 let _ = writeln!(
525 s,
526 " {} {}",
527 if o.ok() { green("pass") } else { red("FAIL") },
528 o.command
529 );
530 if !o.ok() {
531 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
532 }
533 }
534 }
535
536 if let Some(m) = &state.merge {
537 let _ = writeln!(s, "\n{}", bold("merge"));
538 if m.mode == MergeMode::None {
539 let _ = writeln!(
543 s,
544 " mode None {}",
545 cyan("not landed — nothing to do by design")
546 );
547 if let Some(w) = state.winner() {
548 let _ = writeln!(
549 s,
550 " branch {} still exists, unmerged into {}",
551 w.branch, state.base_branch
552 );
553 }
554 let _ = writeln!(
560 s,
561 " rebase onto {} before merging by hand{}",
562 state.base_branch,
563 if state.config.merge.style == MergeStyle::Squash {
564 ", and pass an explicit commit message — a squash merge \
565 otherwise inherits the candidate's placeholder subject"
566 } else {
567 ""
568 }
569 );
570 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
571 } else {
572 let _ = writeln!(
573 s,
574 " mode {:?} {}\n {}",
575 m.mode,
576 if m.ok {
577 green("ok")
578 } else {
579 yellow("not merged")
580 },
581 m.detail.lines().next().unwrap_or("")
582 );
583 }
584 }
585
586 if !state.leaks.is_empty() {
587 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
588 for l in &state.leaks {
589 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
590 }
591 }
592
593 if let Some(w) = state.winner()
594 && !w.folded
595 {
596 let _ = writeln!(
597 s,
598 "\n{} {}\n branch {}",
599 bold("winner worktree"),
600 w.worktree.display(),
601 w.branch
602 );
603 }
604 s
605}
606
607pub fn active_seats(state: &RunState, live: bool) -> String {
622 if state.active.is_empty() {
623 return String::new();
624 }
625 let mut s = String::new();
626 let _ = writeln!(s, "\n{}", bold("running now"));
627 if !live {
628 let _ = writeln!(
629 s,
630 " {}",
631 yellow(
632 "no live daemon claims this run right now — likely left behind by a killed process"
633 )
634 );
635 }
636 let now = jiff::Timestamp::now();
637 for (seat, a) in &state.active {
638 let retry = if a.attempt > 0 {
639 format!(" retry {}", a.attempt)
640 } else {
641 String::new()
642 };
643 let _ = writeln!(
644 s,
645 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
646 seat,
647 a.node,
648 a.elapsed_secs(now),
649 a.remaining_secs(now),
650 a.timeout_secs
651 );
652 }
653 s
654}
655
656pub fn stats(stats: &Stats) -> String {
658 let t = &stats.totals;
659 let mut s = String::new();
660 let _ = writeln!(s, "{}", bold("runs"));
661 let _ = writeln!(
662 s,
663 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
664 t.runs,
665 t.merged,
666 t.ready,
667 t.blocked,
668 t.failed,
669 t.completion_rate()
670 );
671 if t.tallied > 0 {
672 let _ = writeln!(
673 s,
674 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
675 {} of those changed a mind, {} converged to unanimous",
676 t.tallied,
677 t.split,
678 t.split_rate(),
679 t.deliberated,
680 t.minds_changed,
681 t.converged
682 );
683 }
684
685 if !stats.agents.is_empty() {
686 let _ = writeln!(
687 s,
688 "\n{}",
689 bold("implementation (relative, on this workload)")
690 );
691 let _ = writeln!(
692 s,
693 " {:<14}{:>6}{:>8}{:>8}{:>8}",
694 "agent", "won", "entered", "rate", "empty"
695 );
696 for a in &stats.agents {
697 let _ = writeln!(
698 s,
699 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
700 a.agent,
701 a.wins,
702 a.entered,
703 a.win_rate(),
704 a.empty
705 );
706 }
707 }
708
709 if !stats.reviewers.is_empty() {
710 let _ = writeln!(s, "\n{}", bold("review"));
711 let _ = writeln!(
712 s,
713 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
714 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
715 );
716 for r in &stats.reviewers {
717 let _ = writeln!(
718 s,
719 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
720 r.agent,
721 r.rounds,
722 r.submitted,
723 r.adopted_per_round(),
724 r.precision(),
725 r.unique_rate(),
726 r.timeout_rate()
727 );
728 }
729 }
730
731 if stats.e2e.rounds > 0 {
732 let _ = writeln!(s, "\n{}", bold("verification"));
733 let _ = writeln!(
734 s,
735 " {} rounds ran e2e, {} failed, {} of those with a clean static \
736 review ({:.0}% sole detections)",
737 stats.e2e.rounds,
738 stats.e2e.failures,
739 stats.e2e.sole_detections,
740 stats.e2e.sole_rate()
741 );
742 }
743 s
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::config::Config;
750 use crate::run::{
751 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
752 Tally,
753 };
754 use std::collections::BTreeMap;
755 use std::path::PathBuf;
756 use std::sync::{Mutex, MutexGuard};
757
758 static SERIAL: Mutex<()> = Mutex::new(());
760
761 fn plain() -> MutexGuard<'static, ()> {
762 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
763 set_color(false);
764 guard
765 }
766
767 fn state() -> RunState {
768 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
773 let mut s = RunState::new(
774 PathBuf::from("/repo"),
775 "main".to_owned(),
776 "abcdef1234".to_owned(),
777 "add retries to the uploader".to_owned(),
778 Config::default(),
779 );
780 s.candidates = vec![Candidate {
781 index: 0,
782 label: 'A',
783 agent: "opus".to_owned(),
784 branch: "magi/x/A".to_owned(),
785 worktree: PathBuf::from("/wt/A"),
786 summary: String::new(),
787 stat: String::new(),
788 files: 3,
789 commits: 2,
790 empty: false,
791 failed: None,
792 duration_ms: 42_000,
793 folded: false,
794 }];
795 s.tally = Some(Tally {
796 first_choice: BTreeMap::from([('A', 3)]),
797 borda: BTreeMap::new(),
798 winner: 'A',
799 rankings: 3,
800 unanimous_initial: true,
801 deliberated: false,
802 changed_votes: 0,
803 unanimous_final: true,
804 tie_break: None,
805 judges: 3,
806 present: 3,
807 quorum: 2,
808 met_quorum: true,
809 uncontested: None,
810 });
811 s
812 }
813
814 #[test]
815 fn run_report_names_the_winner_and_its_author() {
816 let _guard = plain();
817 let text = run(&state());
818 assert!(text.contains("<- winner"), "{text}");
819 assert!(text.contains("opus"));
820 assert!(text.contains("3 files, 2 commits"));
821 assert!(text.contains("winner A"));
822 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
823 }
824
825 #[test]
826 fn colour_is_emitted_only_when_enabled() {
827 let _guard = plain();
828 set_color(true);
829 let coloured = run(&state());
830 set_color(false);
831 let plain = run(&state());
832 assert!(coloured.contains('\x1b'));
833 assert!(!plain.contains('\x1b'));
834 assert!(coloured.len() > plain.len());
835 }
836
837 #[test]
838 fn list_line_is_single_line() {
839 let _guard = plain();
840 let l = line(&state());
841 assert_eq!(l.lines().count(), 1);
842 assert!(l.contains("add retries"));
843 assert!(l.contains("win A (opus)"));
844 }
845
846 #[test]
847 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
848 let _guard = plain();
849 let mut s = state();
850 s.tally = Some(Tally {
851 first_choice: BTreeMap::from([('A', 0)]),
852 borda: BTreeMap::new(),
853 winner: 'A',
854 rankings: 0,
855 unanimous_initial: false,
856 deliberated: false,
857 changed_votes: 0,
858 unanimous_final: false,
859 tie_break: None,
860 judges: 0,
861 present: 0,
862 quorum: 0,
863 met_quorum: true,
864 uncontested: Some(
865 "only candidate A produced a usable change; no panel was asked".to_owned(),
866 ),
867 });
868 let text = run(&s);
869 assert!(
870 !text.contains("0/3"),
871 "no panel sat, so the judges line must not read as one that collapsed: {text}"
872 );
873 assert!(!text.contains("no usable ranking"), "{text}");
874 assert!(!text.contains("still split"), "{text}");
875 assert!(!text.contains("BELOW QUORUM"), "{text}");
876 assert!(
877 text.contains("not needed"),
878 "the report must say judging was skipped, not silent: {text}"
879 );
880 assert!(text.contains("winner A"));
881 }
882
883 #[test]
884 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
885 let _guard = plain();
886 let mut s = state();
887 s.tally = Some(Tally {
888 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
889 borda: BTreeMap::new(),
890 winner: 'A',
891 rankings: 1,
892 unanimous_initial: false,
893 deliberated: false,
894 changed_votes: 0,
895 unanimous_final: false,
896 tie_break: None,
897 judges: 3,
898 present: 1,
899 quorum: 2,
900 met_quorum: false,
901 uncontested: None,
902 });
903 let text = run(&s);
904 assert!(text.contains("1/3"), "{text}");
905 assert!(
906 text.contains("BELOW QUORUM"),
907 "a real collapse must still be flagged: {text}"
908 );
909 assert!(
910 !text.contains("not needed"),
911 "a collapsed panel must not be described as one that was never asked: {text}"
912 );
913 }
914
915 #[test]
916 fn a_mode_none_merge_does_not_read_as_landed() {
917 let _guard = plain();
918 let mut s = state();
919 s.merge = Some(MergeOutcome {
920 mode: crate::config::MergeMode::None,
921 ok: true,
922 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
923 });
924 let text = run(&s);
925 assert!(
926 !text.contains(" ok"),
927 "mode none must not be shown as a landed merge: {text}"
928 );
929 assert!(text.contains("not landed"), "{text}");
930 assert!(
931 text.contains("branch magi/x/A"),
932 "the report must say what's left behind: {text}"
933 );
934 assert!(
935 text.contains("rebase"),
936 "the report must point at the hand-landing steps: {text}"
937 );
938 assert!(
939 !text.contains("placeholder subject"),
940 "the default merge style is `merge`, which never inherits a \
941 placeholder subject, so the squash caveat must not appear: {text}"
942 );
943 }
944
945 #[test]
946 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
947 let _guard = plain();
948 let mut s = state();
949 s.config.merge.style = MergeStyle::Squash;
950 s.merge = Some(MergeOutcome {
951 mode: crate::config::MergeMode::None,
952 ok: true,
953 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
954 retries\""
955 .to_owned(),
956 });
957 let text = run(&s);
958 assert!(
959 text.contains("placeholder subject"),
960 "a squash-style manual merge must warn about the missing message: {text}"
961 );
962 assert!(text.contains("--squash"), "{text}");
963 }
964
965 #[test]
966 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
967 let _guard = plain();
968 let mut s = state();
969 s.tally = Some(Tally {
970 first_choice: BTreeMap::from([('A', 0)]),
971 borda: BTreeMap::new(),
972 winner: 'A',
973 rankings: 0,
974 unanimous_initial: false,
975 deliberated: false,
976 changed_votes: 0,
977 unanimous_final: false,
978 tie_break: None,
979 judges: 0,
980 present: 0,
981 quorum: 0,
982 met_quorum: true,
983 uncontested: Some("only candidate A produced a usable change".to_owned()),
984 });
985 let l = line(&s);
986 assert!(
987 !l.contains("judges") && !l.contains("quorum"),
988 "an uncontested run must not carry the same badge a short panel gets: {l}"
989 );
990 }
991
992 #[test]
993 fn long_instructions_are_elided() {
994 let _guard = plain();
995 let mut s = state();
996 s.instruction = "x".repeat(200);
997 assert!(line(&s).contains('…'));
998 }
999
1000 #[test]
1001 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1002 let _guard = plain();
1003 let mut lost = state();
1004 lost.reviews = vec![ReviewRound {
1005 round: 1,
1006 head: "abc1234".to_owned(),
1007 reviews: Vec::new(),
1008 e2e: Vec::new(),
1009 verify_retried: false,
1010 fix: Some(FixRecord {
1011 agent: "opus".to_owned(),
1012 addressed: Vec::new(),
1013 rejected: Vec::new(),
1014 notes: String::new(),
1015 committed: true,
1016 failed: Some("timed out".to_owned()),
1017 duration_ms: 0,
1018 }),
1019 blocking: 3,
1020 answered: 0,
1021 expected: 0,
1022 clean: false,
1023 progressed: false,
1024 vote_split: false,
1025 reconsideration: Vec::new(),
1026 verdict: None,
1027 }];
1028 let text = run(&lost);
1029 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1030 assert!(
1031 !text.contains("0 addressed"),
1032 "a lost report must never read as `0 addressed`: {text}"
1033 );
1034
1035 let mut rejected_all = state();
1036 rejected_all.reviews = vec![ReviewRound {
1037 round: 1,
1038 head: "abc1234".to_owned(),
1039 reviews: Vec::new(),
1040 e2e: Vec::new(),
1041 verify_retried: false,
1042 fix: Some(FixRecord {
1043 agent: "opus".to_owned(),
1044 addressed: Vec::new(),
1045 rejected: Vec::new(),
1046 notes: String::new(),
1047 committed: true,
1048 failed: None,
1049 duration_ms: 0,
1050 }),
1051 blocking: 3,
1052 answered: 0,
1053 expected: 0,
1054 clean: false,
1055 progressed: false,
1056 vote_split: false,
1057 reconsideration: Vec::new(),
1058 verdict: None,
1059 }];
1060 let text2 = run(&rejected_all);
1061 assert!(
1062 text2.contains("0 addressed / 0 rejected"),
1063 "a round the fixer actually reported on keeps the count: {text2}"
1064 );
1065 }
1066
1067 #[test]
1068 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1069 use crate::run::ReviewRevoteRecord;
1070 use crate::verdict::ReviewVote;
1071
1072 let _guard = plain();
1073 let mut s = state();
1074 s.reviews = vec![ReviewRound {
1075 round: 1,
1076 head: "abc1234".to_owned(),
1077 reviews: vec![
1078 ReviewRecord {
1079 reviewer: 1,
1080 agent: "alpha".to_owned(),
1081 summary: String::new(),
1082 findings: Vec::new(),
1083 vote: Some(ReviewVote::Approve),
1084 failed: None,
1085 duration_ms: 0,
1086 },
1087 ReviewRecord {
1088 reviewer: 2,
1089 agent: "beta".to_owned(),
1090 summary: String::new(),
1091 findings: Vec::new(),
1092 vote: Some(ReviewVote::Reject),
1093 failed: None,
1094 duration_ms: 0,
1095 },
1096 ],
1097 e2e: Vec::new(),
1098 verify_retried: false,
1099 fix: None,
1100 blocking: 0,
1101 answered: 2,
1102 expected: 2,
1103 clean: false,
1104 progressed: false,
1105 vote_split: true,
1106 reconsideration: vec![ReviewRevoteRecord {
1107 reviewer: 2,
1108 agent: "beta".to_owned(),
1109 vote: Some(ReviewVote::ApproveWithFindings),
1110 reason: "the other seat's read holds up".to_owned(),
1111 failed: None,
1112 }],
1113 verdict: Some(ReviewVote::ApproveWithFindings),
1114 }];
1115 let text = run(&s);
1116 assert!(text.contains("review-1 vote"), "{text}");
1117 assert!(text.contains("review-2 vote"), "{text}");
1118 assert!(text.contains("panel split"), "{text}");
1119 assert!(text.contains("reconsideration"), "{text}");
1120 assert!(text.contains("the other seat's read holds up"), "{text}");
1121 }
1122
1123 #[test]
1124 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1125 let _guard = plain();
1131 let mut s = state();
1132 s.reviews = vec![ReviewRound {
1133 round: 1,
1134 head: "abc1234".to_owned(),
1135 reviews: vec![
1136 ReviewRecord {
1137 reviewer: 1,
1138 agent: "alpha".to_owned(),
1139 summary: String::new(),
1140 findings: Vec::new(),
1141 vote: None,
1142 failed: None,
1143 duration_ms: 0,
1144 },
1145 ReviewRecord {
1146 reviewer: 2,
1147 agent: "beta".to_owned(),
1148 summary: String::new(),
1149 findings: Vec::new(),
1150 vote: None,
1151 failed: Some("agent timed out".to_owned()),
1152 duration_ms: 0,
1153 },
1154 ],
1155 e2e: Vec::new(),
1156 verify_retried: false,
1157 fix: Some(FixRecord {
1158 agent: "opus".to_owned(),
1159 addressed: Vec::new(),
1160 rejected: Vec::new(),
1161 notes: String::new(),
1162 committed: true,
1163 failed: Some("timed out".to_owned()),
1164 duration_ms: 0,
1165 }),
1166 blocking: 0,
1167 answered: 1,
1168 expected: 2,
1169 clean: false,
1170 progressed: true,
1171 vote_split: false,
1172 reconsideration: Vec::new(),
1173 verdict: None,
1174 }];
1175 let text = run(&s);
1176 assert!(text.contains("incomplete"), "{text}");
1177 assert!(text.contains("1/2 reviewers answered"), "{text}");
1178 assert!(text.contains("review-2: agent timed out"), "{text}");
1179 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1180 assert!(
1181 !text.contains("clean"),
1182 "a round missing half its panel must never render as clean: {text}"
1183 );
1184 }
1185
1186 #[test]
1187 fn a_build_failure_is_not_reported_as_a_test_failure() {
1188 let _guard = plain();
1189 let mut s = state();
1190 s.reviews = vec![ReviewRound {
1191 round: 1,
1192 head: "abc1234".to_owned(),
1193 reviews: Vec::new(),
1194 e2e: vec![CommandOutcome {
1195 command: "cargo test".to_owned(),
1196 code: Some(1),
1197 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1198 duration_ms: 100,
1199 }],
1200 verify_retried: true,
1201 fix: None,
1202 blocking: 0,
1203 answered: 0,
1204 expected: 0,
1205 clean: false,
1206 progressed: false,
1207 vote_split: false,
1208 reconsideration: Vec::new(),
1209 verdict: None,
1210 }];
1211 let text = run(&s);
1212 assert!(text.contains("could not run"), "{text}");
1213 assert!(text.contains("retried once"), "{text}");
1214 assert!(!text.contains("e2e RED"), "{text}");
1215 }
1216
1217 #[test]
1218 fn a_declined_finding_shows_its_reason() {
1219 use crate::verdict::{Finding, Rejection, Severity};
1220
1221 let _guard = plain();
1222 let mut s = state();
1223 s.status = RunStatus::Ready;
1224 s.reviews = vec![ReviewRound {
1225 round: 1,
1226 head: "deadbee".to_owned(),
1227 reviews: vec![ReviewRecord {
1228 reviewer: 1,
1229 agent: "alpha".to_owned(),
1230 summary: String::new(),
1231 findings: vec![Finding {
1232 id: "R1-1-1".to_owned(),
1233 severity: Severity::Major,
1234 file: None,
1235 line: None,
1236 title: "still open".to_owned(),
1237 detail: String::new(),
1238 }],
1239 vote: None,
1240 failed: None,
1241 duration_ms: 0,
1242 }],
1243 e2e: vec![CommandOutcome {
1244 command: "cargo test".to_owned(),
1245 code: Some(0),
1246 output_tail: String::new(),
1247 duration_ms: 0,
1248 }],
1249 verify_retried: false,
1250 fix: Some(FixRecord {
1251 agent: "alpha".to_owned(),
1252 addressed: Vec::new(),
1253 rejected: vec![Rejection {
1254 id: "R1-1-2".to_owned(),
1255 why: "cannot be triggered from any caller".to_owned(),
1256 }],
1257 notes: String::new(),
1258 committed: true,
1259 failed: None,
1260 duration_ms: 0,
1261 }),
1262 blocking: 1,
1263 answered: 1,
1264 expected: 1,
1265 clean: false,
1266 progressed: true,
1267 vote_split: false,
1268 reconsideration: Vec::new(),
1269 verdict: None,
1270 }];
1271
1272 let text = run(&s);
1273 assert!(text.contains("R1-1-2"), "{text}");
1274 assert!(text.contains("cannot be triggered"), "{text}");
1275 assert!(text.contains("still open"), "{text}");
1276 assert!(
1277 text.contains("handed off"),
1278 "a mergeable run with an open round must say so: {text}"
1279 );
1280 }
1281
1282 #[test]
1283 fn a_failing_gate_command_shows_its_output() {
1284 let _guard = plain();
1285 let mut s = state();
1286 s.status = RunStatus::Blocked;
1287 s.gate = vec![CommandOutcome {
1288 command: "cargo make check".to_owned(),
1289 code: Some(101),
1290 output_tail: "error[E0308]: mismatched types".to_owned(),
1291 duration_ms: 0,
1292 }];
1293
1294 let text = run(&s);
1295 assert!(text.contains("mismatched types"), "{text}");
1296 }
1297
1298 #[test]
1299 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1300 let _guard = plain();
1301 let mut s = state();
1302 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1303 let text = active_seats(&s, true);
1304 assert!(text.contains("running now"));
1305 assert!(text.contains("judge-2"));
1306 assert!(text.contains("judge"));
1307 assert!(!text.contains("no live daemon"), "{text}");
1308 }
1309
1310 #[test]
1311 fn active_seats_flags_a_leftover_from_a_dead_process() {
1312 let _guard = plain();
1313 let mut s = state();
1314 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1315 let text = active_seats(&s, false);
1316 assert!(
1317 text.contains("no live daemon"),
1318 "a stale entry must not read as running: {text}"
1319 );
1320 }
1321
1322 #[test]
1323 fn active_seats_is_empty_when_nothing_is_running() {
1324 let _guard = plain();
1325 assert_eq!(active_seats(&state(), true), "");
1326 }
1327
1328 #[test]
1329 fn stats_table_renders_without_runs() {
1330 let _guard = plain();
1331 let text = stats(&Stats::default());
1332 assert!(text.contains("0 total"));
1333 assert!(!text.contains("implementation"));
1334 }
1335}