1use std::fmt::Write as _;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use crate::config::{MergeMode, MergeStyle};
16use crate::run::{CommandOutcome, E2eStatus, 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 = match r.e2e_status() {
328 E2eStatus::NotConfigured => dim("no e2e"),
329 E2eStatus::Deferred => yellow(&format!(
330 "e2e deferred{}",
331 r.e2e_defer_reason
332 .as_deref()
333 .map(|why| format!(" ({why})"))
334 .unwrap_or_default()
335 )),
336 E2eStatus::Passed => green("e2e green"),
337 E2eStatus::Failed if r.e2e.iter().any(CommandOutcome::build_failed) => {
338 yellow("e2e could not run (build/link failure)")
339 }
340 E2eStatus::Failed => red("e2e RED"),
341 };
342 let e2e = if r.verify_retried {
343 format!("{e2e}, retried once")
344 } else {
345 e2e
346 };
347 let status = if r.incomplete() {
351 yellow("incomplete")
352 } else if r.clean {
353 green("clean")
354 } else {
355 yellow("open")
356 };
357 let panel = if r.incomplete() {
361 let missing: Vec<String> = r
362 .reviews
363 .iter()
364 .filter_map(|x| {
365 x.failed
366 .as_ref()
367 .map(|why| format!("review-{}: {why}", x.reviewer))
368 })
369 .collect();
370 format!(
371 " {}/{} reviewers answered ({})",
372 r.answered,
373 r.expected,
374 missing.join(", ")
375 )
376 } else {
377 String::new()
378 };
379 let verdict = r.verdict.map_or(String::new(), |v| {
385 format!(
386 ", verdict {}{}",
387 vote_tag(v),
388 if r.vote_split { " (panel split)" } else { "" }
389 )
390 });
391 let _ = writeln!(
392 s,
393 " round {} {} @ {}{}{panel} {raised} finding(s), {} blocking, {e2e}{verdict}{}",
394 r.round,
395 status,
396 short(&r.head),
397 r.verified_head.as_ref().map_or(String::new(), |head| {
398 format!(" (verified @ {})", short(head))
399 }),
400 r.blocking,
401 r.fix.as_ref().map_or(String::new(), |f| {
402 let tree = if r.progressed {
403 green("changed")
404 } else {
405 yellow("unchanged")
406 };
407 match &f.failed {
408 Some(reason) => format!(
413 " fix: {}, tree {tree}{}",
414 yellow(&format!("adoption report lost ({reason})")),
415 if f.committed {
416 String::new()
417 } else {
418 red(" (NO COMMIT)")
419 }
420 ),
421 None => format!(
422 " fix: {} addressed / {} rejected, tree {tree}{}",
423 f.addressed.len(),
424 f.rejected.len(),
425 if f.committed {
426 String::new()
427 } else {
428 red(" (NO COMMIT)")
429 }
430 ),
431 }
432 })
433 );
434 for rec in &r.reviews {
435 if let Some(vote) = rec.vote {
436 let _ = writeln!(s, " review-{} vote {}", rec.reviewer, vote_tag(vote));
437 }
438 for f in &rec.findings {
439 let adopted = r
440 .fix
441 .as_ref()
442 .is_some_and(|fix| fix.addressed.contains(&f.id));
443 let _ = writeln!(
444 s,
445 " {} [{:?}] {}{}",
446 dim(&f.id),
447 f.severity,
448 f.title,
449 if adopted {
450 green(" fixed")
451 } else {
452 String::new()
453 }
454 );
455 }
456 }
457 if let Some(fix) = &r.fix {
458 for rej in &fix.rejected {
459 let _ = writeln!(
460 s,
461 " {} {}: {}",
462 dim(&rej.id),
463 yellow("declined"),
464 rej.why
465 );
466 }
467 }
468 if !r.reconsideration.is_empty() {
472 let _ = writeln!(s, " {}", dim("reconsideration:"));
473 for rv in &r.reconsideration {
474 match rv.vote {
475 Some(v) => {
476 let _ = writeln!(
477 s,
478 " review-{} -> {} {}",
479 rv.reviewer,
480 vote_tag(v),
481 rv.reason
482 );
483 }
484 None => {
485 let _ = writeln!(
486 s,
487 " review-{} -> {}",
488 rv.reviewer,
489 red(&format!(
490 "no revote ({})",
491 rv.failed.as_deref().unwrap_or("unknown")
492 ))
493 );
494 }
495 }
496 }
497 }
498 }
499 if state.handed_off_with_open_findings() {
500 let _ = writeln!(
501 s,
502 "\n {}",
503 yellow(&format!(
504 "handed off with {} finding(s) still open — gate and e2e were green; \
505 see above for what a person should still look at",
506 state.open_findings().len()
507 ))
508 );
509 }
510 }
511
512 if let Some(bs) = &state.base_sync {
513 let _ = writeln!(s, "\n{}", bold("base sync"));
514 let status = if let Some(c) = &bs.conflict {
515 red(&format!("conflict: {}", first_line(c)))
516 } else if bs.behind == 0 {
517 green("in sync")
518 } else {
519 yellow(&format!("{} commit(s) behind, not yet rebased", bs.behind))
520 };
521 let _ = writeln!(
522 s,
523 " {} @ {} {status}{}",
524 state.base_branch,
525 short(&bs.tip),
526 if bs.attempts > 0 {
527 format!(" ({} rebase attempt(s))", bs.attempts)
528 } else {
529 String::new()
530 }
531 );
532 }
533
534 if !state.gate.is_empty() {
535 let _ = writeln!(s, "\n{}", bold("gate"));
536 for o in &state.gate {
537 let _ = writeln!(
538 s,
539 " {} {}",
540 if o.ok() { green("pass") } else { red("FAIL") },
541 o.command
542 );
543 if !o.ok() {
544 let _ = writeln!(s, "{}", dim(&tail(&o.output_tail, 2_000)));
545 }
546 }
547 }
548
549 if let Some(m) = &state.merge {
550 let _ = writeln!(s, "\n{}", bold("merge"));
551 if m.mode == MergeMode::None {
552 let _ = writeln!(
556 s,
557 " mode None {}",
558 cyan("not landed — nothing to do by design")
559 );
560 if let Some(w) = state.winner() {
561 let _ = writeln!(
562 s,
563 " branch {} still exists, unmerged into {}",
564 w.branch, state.base_branch
565 );
566 }
567 let _ = writeln!(
573 s,
574 " rebase onto {} before merging by hand{}",
575 state.base_branch,
576 if state.config.merge.style == MergeStyle::Squash {
577 ", and pass an explicit commit message — a squash merge \
578 otherwise inherits the candidate's placeholder subject"
579 } else {
580 ""
581 }
582 );
583 let _ = writeln!(s, " {}", m.detail.lines().next().unwrap_or(""));
584 } else {
585 let _ = writeln!(
586 s,
587 " mode {:?} {}\n {}",
588 m.mode,
589 if m.ok {
590 green("ok")
591 } else {
592 yellow("not merged")
593 },
594 m.detail.lines().next().unwrap_or("")
595 );
596 }
597 }
598
599 if !state.leaks.is_empty() {
600 let _ = writeln!(s, "\n{}", bold(&yellow("blindness warnings")));
601 for l in &state.leaks {
602 let _ = writeln!(s, " {} x{} in {}", l.token, l.count, l.site);
603 }
604 }
605
606 if let Some(w) = state.winner()
607 && !w.folded
608 {
609 let _ = writeln!(
610 s,
611 "\n{} {}\n branch {}",
612 bold("winner worktree"),
613 w.worktree.display(),
614 w.branch
615 );
616 }
617 s
618}
619
620pub fn active_seats(state: &RunState, live: bool) -> String {
635 if state.active.is_empty() {
636 return String::new();
637 }
638 let mut s = String::new();
639 let _ = writeln!(s, "\n{}", bold("running now"));
640 if !live {
641 let _ = writeln!(
642 s,
643 " {}",
644 yellow(
645 "no live daemon claims this run right now — likely left behind by a killed process"
646 )
647 );
648 }
649 let now = jiff::Timestamp::now();
650 for (seat, a) in &state.active {
651 let retry = if a.attempt > 0 {
652 format!(" retry {}", a.attempt)
653 } else {
654 String::new()
655 };
656 let _ = writeln!(
657 s,
658 " {:<12} {:<12}{retry} {}s elapsed, {}s left of {}s",
659 seat,
660 a.node,
661 a.elapsed_secs(now),
662 a.remaining_secs(now),
663 a.timeout_secs
664 );
665 }
666 s
667}
668
669pub fn stats(stats: &Stats) -> String {
671 let t = &stats.totals;
672 let mut s = String::new();
673 let _ = writeln!(s, "{}", bold("runs"));
674 let _ = writeln!(
675 s,
676 " {} total - {} merged, {} ready, {} blocked, {} failed ({:.0}% completion)",
677 t.runs,
678 t.merged,
679 t.ready,
680 t.blocked,
681 t.failed,
682 t.completion_rate()
683 );
684 if t.tallied > 0 {
685 let _ = writeln!(
686 s,
687 " {} tallied - {} split on first choice ({:.0}%), {} deliberated, \
688 {} of those changed a mind, {} converged to unanimous",
689 t.tallied,
690 t.split,
691 t.split_rate(),
692 t.deliberated,
693 t.minds_changed,
694 t.converged
695 );
696 }
697
698 if !stats.agents.is_empty() {
699 let _ = writeln!(
700 s,
701 "\n{}",
702 bold("implementation (relative, on this workload)")
703 );
704 let _ = writeln!(
705 s,
706 " {:<14}{:>6}{:>8}{:>8}{:>8}",
707 "agent", "won", "entered", "rate", "empty"
708 );
709 for a in &stats.agents {
710 let _ = writeln!(
711 s,
712 " {:<14}{:>6}{:>8}{:>7.0}%{:>8}",
713 a.agent,
714 a.wins,
715 a.entered,
716 a.win_rate(),
717 a.empty
718 );
719 }
720 }
721
722 if !stats.reviewers.is_empty() {
723 let _ = writeln!(s, "\n{}", bold("review"));
724 let _ = writeln!(
725 s,
726 " {:<14}{:>8}{:>10}{:>11}{:>9}{:>9}{:>9}",
727 "reviewer", "rounds", "submitted", "adopted/rd", "precision", "unique", "timeout"
728 );
729 for r in &stats.reviewers {
730 let _ = writeln!(
731 s,
732 " {:<14}{:>8}{:>10}{:>11.2}{:>8.0}%{:>8.0}%{:>8.0}%",
733 r.agent,
734 r.rounds,
735 r.submitted,
736 r.adopted_per_round(),
737 r.precision(),
738 r.unique_rate(),
739 r.timeout_rate()
740 );
741 }
742 }
743
744 if stats.e2e.rounds > 0 || stats.e2e.deferred > 0 {
745 let _ = writeln!(s, "\n{}", bold("verification"));
746 let _ = writeln!(
747 s,
748 " {} rounds ran e2e, {} failed, {} of those with a clean static \
749 review ({:.0}% sole detections), {} round(s) deferred it to the fixer",
750 stats.e2e.rounds,
751 stats.e2e.failures,
752 stats.e2e.sole_detections,
753 stats.e2e.sole_rate(),
754 stats.e2e.deferred
755 );
756 }
757 s
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use crate::config::Config;
764 use crate::run::{
765 Candidate, CommandOutcome, FixRecord, MergeOutcome, ReviewRecord, ReviewRound, RunState,
766 Tally,
767 };
768 use std::collections::BTreeMap;
769 use std::path::PathBuf;
770 use std::sync::{Mutex, MutexGuard};
771
772 static SERIAL: Mutex<()> = Mutex::new(());
774
775 fn plain() -> MutexGuard<'static, ()> {
776 let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
777 set_color(false);
778 guard
779 }
780
781 fn state() -> RunState {
782 crate::run::set_home(std::env::temp_dir().join("magi-report-test-home"));
787 let mut s = RunState::new(
788 PathBuf::from("/repo"),
789 "main".to_owned(),
790 "abcdef1234".to_owned(),
791 "add retries to the uploader".to_owned(),
792 Config::default(),
793 );
794 s.candidates = vec![Candidate {
795 index: 0,
796 label: 'A',
797 agent: "opus".to_owned(),
798 branch: "magi/x/A".to_owned(),
799 worktree: PathBuf::from("/wt/A"),
800 summary: String::new(),
801 stat: String::new(),
802 files: 3,
803 commits: 2,
804 empty: false,
805 failed: None,
806 duration_ms: 42_000,
807 folded: false,
808 }];
809 s.tally = Some(Tally {
810 first_choice: BTreeMap::from([('A', 3)]),
811 borda: BTreeMap::new(),
812 winner: 'A',
813 rankings: 3,
814 unanimous_initial: true,
815 deliberated: false,
816 changed_votes: 0,
817 unanimous_final: true,
818 tie_break: None,
819 judges: 3,
820 present: 3,
821 quorum: 2,
822 met_quorum: true,
823 uncontested: None,
824 });
825 s
826 }
827
828 #[test]
829 fn run_report_names_the_winner_and_its_author() {
830 let _guard = plain();
831 let text = run(&state());
832 assert!(text.contains("<- winner"), "{text}");
833 assert!(text.contains("opus"));
834 assert!(text.contains("3 files, 2 commits"));
835 assert!(text.contains("winner A"));
836 assert!(!text.contains('\x1b'), "colour leaked into a plain render");
837 }
838
839 #[test]
840 fn colour_is_emitted_only_when_enabled() {
841 let _guard = plain();
842 set_color(true);
843 let coloured = run(&state());
844 set_color(false);
845 let plain = run(&state());
846 assert!(coloured.contains('\x1b'));
847 assert!(!plain.contains('\x1b'));
848 assert!(coloured.len() > plain.len());
849 }
850
851 #[test]
852 fn list_line_is_single_line() {
853 let _guard = plain();
854 let l = line(&state());
855 assert_eq!(l.lines().count(), 1);
856 assert!(l.contains("add retries"));
857 assert!(l.contains("win A (opus)"));
858 }
859
860 #[test]
861 fn an_uncontested_run_does_not_read_as_a_collapsed_panel() {
862 let _guard = plain();
863 let mut s = state();
864 s.tally = Some(Tally {
865 first_choice: BTreeMap::from([('A', 0)]),
866 borda: BTreeMap::new(),
867 winner: 'A',
868 rankings: 0,
869 unanimous_initial: false,
870 deliberated: false,
871 changed_votes: 0,
872 unanimous_final: false,
873 tie_break: None,
874 judges: 0,
875 present: 0,
876 quorum: 0,
877 met_quorum: true,
878 uncontested: Some(
879 "only candidate A produced a usable change; no panel was asked".to_owned(),
880 ),
881 });
882 let text = run(&s);
883 assert!(
884 !text.contains("0/3"),
885 "no panel sat, so the judges line must not read as one that collapsed: {text}"
886 );
887 assert!(!text.contains("no usable ranking"), "{text}");
888 assert!(!text.contains("still split"), "{text}");
889 assert!(!text.contains("BELOW QUORUM"), "{text}");
890 assert!(
891 text.contains("not needed"),
892 "the report must say judging was skipped, not silent: {text}"
893 );
894 assert!(text.contains("winner A"));
895 }
896
897 #[test]
898 fn a_below_quorum_run_still_reads_as_a_collapsed_panel() {
899 let _guard = plain();
900 let mut s = state();
901 s.tally = Some(Tally {
902 first_choice: BTreeMap::from([('A', 1), ('B', 0)]),
903 borda: BTreeMap::new(),
904 winner: 'A',
905 rankings: 1,
906 unanimous_initial: false,
907 deliberated: false,
908 changed_votes: 0,
909 unanimous_final: false,
910 tie_break: None,
911 judges: 3,
912 present: 1,
913 quorum: 2,
914 met_quorum: false,
915 uncontested: None,
916 });
917 let text = run(&s);
918 assert!(text.contains("1/3"), "{text}");
919 assert!(
920 text.contains("BELOW QUORUM"),
921 "a real collapse must still be flagged: {text}"
922 );
923 assert!(
924 !text.contains("not needed"),
925 "a collapsed panel must not be described as one that was never asked: {text}"
926 );
927 }
928
929 #[test]
930 fn a_mode_none_merge_does_not_read_as_landed() {
931 let _guard = plain();
932 let mut s = state();
933 s.merge = Some(MergeOutcome {
934 mode: crate::config::MergeMode::None,
935 ok: true,
936 detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
937 });
938 let text = run(&s);
939 assert!(
940 !text.contains(" ok"),
941 "mode none must not be shown as a landed merge: {text}"
942 );
943 assert!(text.contains("not landed"), "{text}");
944 assert!(
945 text.contains("branch magi/x/A"),
946 "the report must say what's left behind: {text}"
947 );
948 assert!(
949 text.contains("rebase"),
950 "the report must point at the hand-landing steps: {text}"
951 );
952 assert!(
953 !text.contains("placeholder subject"),
954 "the default merge style is `merge`, which never inherits a \
955 placeholder subject, so the squash caveat must not appear: {text}"
956 );
957 }
958
959 #[test]
960 fn a_mode_none_squash_merge_warns_about_the_placeholder_subject() {
961 let _guard = plain();
962 let mut s = state();
963 s.config.merge.style = MergeStyle::Squash;
964 s.merge = Some(MergeOutcome {
965 mode: crate::config::MergeMode::None,
966 ok: true,
967 detail: "git -C /repo merge --squash magi/x/A && git -C /repo commit -m \"add \
968 retries\""
969 .to_owned(),
970 });
971 let text = run(&s);
972 assert!(
973 text.contains("placeholder subject"),
974 "a squash-style manual merge must warn about the missing message: {text}"
975 );
976 assert!(text.contains("--squash"), "{text}");
977 }
978
979 #[test]
980 fn the_list_line_does_not_flag_an_uncontested_run_as_short_judges() {
981 let _guard = plain();
982 let mut s = state();
983 s.tally = Some(Tally {
984 first_choice: BTreeMap::from([('A', 0)]),
985 borda: BTreeMap::new(),
986 winner: 'A',
987 rankings: 0,
988 unanimous_initial: false,
989 deliberated: false,
990 changed_votes: 0,
991 unanimous_final: false,
992 tie_break: None,
993 judges: 0,
994 present: 0,
995 quorum: 0,
996 met_quorum: true,
997 uncontested: Some("only candidate A produced a usable change".to_owned()),
998 });
999 let l = line(&s);
1000 assert!(
1001 !l.contains("judges") && !l.contains("quorum"),
1002 "an uncontested run must not carry the same badge a short panel gets: {l}"
1003 );
1004 }
1005
1006 #[test]
1007 fn long_instructions_are_elided() {
1008 let _guard = plain();
1009 let mut s = state();
1010 s.instruction = "x".repeat(200);
1011 assert!(line(&s).contains('…'));
1012 }
1013
1014 #[test]
1015 fn a_lost_fix_report_reads_differently_from_zero_adoption() {
1016 let _guard = plain();
1017 let mut lost = state();
1018 lost.reviews = vec![ReviewRound {
1019 round: 1,
1020 head: "abc1234".to_owned(),
1021 verified_head: None,
1022 reviews: Vec::new(),
1023 e2e: Vec::new(),
1024 verify_retried: false,
1025 e2e_deferred: false,
1026 e2e_defer_reason: None,
1027 fix: Some(FixRecord {
1028 agent: "opus".to_owned(),
1029 addressed: Vec::new(),
1030 rejected: Vec::new(),
1031 notes: String::new(),
1032 committed: true,
1033 failed: Some("timed out".to_owned()),
1034 duration_ms: 0,
1035 }),
1036 blocking: 3,
1037 answered: 0,
1038 expected: 0,
1039 clean: false,
1040 progressed: false,
1041 vote_split: false,
1042 reconsideration: Vec::new(),
1043 verdict: None,
1044 }];
1045 let text = run(&lost);
1046 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1047 assert!(
1048 !text.contains("0 addressed"),
1049 "a lost report must never read as `0 addressed`: {text}"
1050 );
1051
1052 let mut rejected_all = state();
1053 rejected_all.reviews = vec![ReviewRound {
1054 round: 1,
1055 head: "abc1234".to_owned(),
1056 verified_head: None,
1057 reviews: Vec::new(),
1058 e2e: Vec::new(),
1059 verify_retried: false,
1060 e2e_deferred: false,
1061 e2e_defer_reason: None,
1062 fix: Some(FixRecord {
1063 agent: "opus".to_owned(),
1064 addressed: Vec::new(),
1065 rejected: Vec::new(),
1066 notes: String::new(),
1067 committed: true,
1068 failed: None,
1069 duration_ms: 0,
1070 }),
1071 blocking: 3,
1072 answered: 0,
1073 expected: 0,
1074 clean: false,
1075 progressed: false,
1076 vote_split: false,
1077 reconsideration: Vec::new(),
1078 verdict: None,
1079 }];
1080 let text2 = run(&rejected_all);
1081 assert!(
1082 text2.contains("0 addressed / 0 rejected"),
1083 "a round the fixer actually reported on keeps the count: {text2}"
1084 );
1085 }
1086
1087 #[test]
1088 fn a_split_round_shows_every_seat_vote_and_the_reconsideration() {
1089 use crate::run::ReviewRevoteRecord;
1090 use crate::verdict::ReviewVote;
1091
1092 let _guard = plain();
1093 let mut s = state();
1094 s.reviews = vec![ReviewRound {
1095 round: 1,
1096 head: "abc1234".to_owned(),
1097 verified_head: None,
1098 reviews: vec![
1099 ReviewRecord {
1100 reviewer: 1,
1101 agent: "alpha".to_owned(),
1102 summary: String::new(),
1103 findings: Vec::new(),
1104 vote: Some(ReviewVote::Approve),
1105 failed: None,
1106 duration_ms: 0,
1107 },
1108 ReviewRecord {
1109 reviewer: 2,
1110 agent: "beta".to_owned(),
1111 summary: String::new(),
1112 findings: Vec::new(),
1113 vote: Some(ReviewVote::Reject),
1114 failed: None,
1115 duration_ms: 0,
1116 },
1117 ],
1118 e2e: Vec::new(),
1119 verify_retried: false,
1120 e2e_deferred: false,
1121 e2e_defer_reason: None,
1122 fix: None,
1123 blocking: 0,
1124 answered: 2,
1125 expected: 2,
1126 clean: false,
1127 progressed: false,
1128 vote_split: true,
1129 reconsideration: vec![ReviewRevoteRecord {
1130 reviewer: 2,
1131 agent: "beta".to_owned(),
1132 vote: Some(ReviewVote::ApproveWithFindings),
1133 reason: "the other seat's read holds up".to_owned(),
1134 failed: None,
1135 }],
1136 verdict: Some(ReviewVote::ApproveWithFindings),
1137 }];
1138 let text = run(&s);
1139 assert!(text.contains("review-1 vote"), "{text}");
1140 assert!(text.contains("review-2 vote"), "{text}");
1141 assert!(text.contains("panel split"), "{text}");
1142 assert!(text.contains("reconsideration"), "{text}");
1143 assert!(text.contains("the other seat's read holds up"), "{text}");
1144 }
1145
1146 #[test]
1147 fn an_incomplete_panel_and_a_lost_fix_report_both_stay_on_the_round_line() {
1148 let _guard = plain();
1154 let mut s = state();
1155 s.reviews = vec![ReviewRound {
1156 round: 1,
1157 head: "abc1234".to_owned(),
1158 verified_head: None,
1159 reviews: vec![
1160 ReviewRecord {
1161 reviewer: 1,
1162 agent: "alpha".to_owned(),
1163 summary: String::new(),
1164 findings: Vec::new(),
1165 vote: None,
1166 failed: None,
1167 duration_ms: 0,
1168 },
1169 ReviewRecord {
1170 reviewer: 2,
1171 agent: "beta".to_owned(),
1172 summary: String::new(),
1173 findings: Vec::new(),
1174 vote: None,
1175 failed: Some("agent timed out".to_owned()),
1176 duration_ms: 0,
1177 },
1178 ],
1179 e2e: Vec::new(),
1180 verify_retried: false,
1181 e2e_deferred: false,
1182 e2e_defer_reason: None,
1183 fix: Some(FixRecord {
1184 agent: "opus".to_owned(),
1185 addressed: Vec::new(),
1186 rejected: Vec::new(),
1187 notes: String::new(),
1188 committed: true,
1189 failed: Some("timed out".to_owned()),
1190 duration_ms: 0,
1191 }),
1192 blocking: 0,
1193 answered: 1,
1194 expected: 2,
1195 clean: false,
1196 progressed: true,
1197 vote_split: false,
1198 reconsideration: Vec::new(),
1199 verdict: None,
1200 }];
1201 let text = run(&s);
1202 assert!(text.contains("incomplete"), "{text}");
1203 assert!(text.contains("1/2 reviewers answered"), "{text}");
1204 assert!(text.contains("review-2: agent timed out"), "{text}");
1205 assert!(text.contains("adoption report lost (timed out)"), "{text}");
1206 assert!(
1207 !text.contains("clean"),
1208 "a round missing half its panel must never render as clean: {text}"
1209 );
1210 }
1211
1212 #[test]
1213 fn a_build_failure_is_not_reported_as_a_test_failure() {
1214 let _guard = plain();
1215 let mut s = state();
1216 s.reviews = vec![ReviewRound {
1217 round: 1,
1218 head: "abc1234".to_owned(),
1219 verified_head: None,
1220 reviews: Vec::new(),
1221 e2e: vec![CommandOutcome {
1222 command: "cargo test".to_owned(),
1223 code: Some(1),
1224 output_tail: "LINK : fatal error LNK1104: cannot open file".to_owned(),
1225 duration_ms: 100,
1226 }],
1227 verify_retried: true,
1228 e2e_deferred: false,
1229 e2e_defer_reason: None,
1230 fix: None,
1231 blocking: 0,
1232 answered: 0,
1233 expected: 0,
1234 clean: false,
1235 progressed: false,
1236 vote_split: false,
1237 reconsideration: Vec::new(),
1238 verdict: None,
1239 }];
1240 let text = run(&s);
1241 assert!(text.contains("could not run"), "{text}");
1242 assert!(text.contains("retried once"), "{text}");
1243 assert!(!text.contains("e2e RED"), "{text}");
1244 }
1245
1246 #[test]
1247 fn a_declined_finding_shows_its_reason() {
1248 use crate::verdict::{Finding, Rejection, Severity};
1249
1250 let _guard = plain();
1251 let mut s = state();
1252 s.status = RunStatus::Ready;
1253 s.reviews = vec![ReviewRound {
1254 round: 1,
1255 head: "deadbee".to_owned(),
1256 verified_head: None,
1257 reviews: vec![ReviewRecord {
1258 reviewer: 1,
1259 agent: "alpha".to_owned(),
1260 summary: String::new(),
1261 findings: vec![Finding {
1262 id: "R1-1-1".to_owned(),
1263 severity: Severity::Major,
1264 file: None,
1265 line: None,
1266 title: "still open".to_owned(),
1267 detail: String::new(),
1268 }],
1269 vote: None,
1270 failed: None,
1271 duration_ms: 0,
1272 }],
1273 e2e: vec![CommandOutcome {
1274 command: "cargo test".to_owned(),
1275 code: Some(0),
1276 output_tail: String::new(),
1277 duration_ms: 0,
1278 }],
1279 verify_retried: false,
1280 e2e_deferred: false,
1281 e2e_defer_reason: None,
1282 fix: Some(FixRecord {
1283 agent: "alpha".to_owned(),
1284 addressed: Vec::new(),
1285 rejected: vec![Rejection {
1286 id: "R1-1-2".to_owned(),
1287 why: "cannot be triggered from any caller".to_owned(),
1288 }],
1289 notes: String::new(),
1290 committed: true,
1291 failed: None,
1292 duration_ms: 0,
1293 }),
1294 blocking: 1,
1295 answered: 1,
1296 expected: 1,
1297 clean: false,
1298 progressed: true,
1299 vote_split: false,
1300 reconsideration: Vec::new(),
1301 verdict: None,
1302 }];
1303
1304 let text = run(&s);
1305 assert!(text.contains("R1-1-2"), "{text}");
1306 assert!(text.contains("cannot be triggered"), "{text}");
1307 assert!(text.contains("still open"), "{text}");
1308 assert!(
1309 text.contains("handed off"),
1310 "a mergeable run with an open round must say so: {text}"
1311 );
1312 }
1313
1314 #[test]
1315 fn a_failing_gate_command_shows_its_output() {
1316 let _guard = plain();
1317 let mut s = state();
1318 s.status = RunStatus::Blocked;
1319 s.gate = vec![CommandOutcome {
1320 command: "cargo make check".to_owned(),
1321 code: Some(101),
1322 output_tail: "error[E0308]: mismatched types".to_owned(),
1323 duration_ms: 0,
1324 }];
1325
1326 let text = run(&s);
1327 assert!(text.contains("mismatched types"), "{text}");
1328 }
1329
1330 #[test]
1331 fn active_seats_shows_who_has_not_answered_and_how_long_is_left() {
1332 let _guard = plain();
1333 let mut s = state();
1334 s.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
1335 let text = active_seats(&s, true);
1336 assert!(text.contains("running now"));
1337 assert!(text.contains("judge-2"));
1338 assert!(text.contains("judge"));
1339 assert!(!text.contains("no live daemon"), "{text}");
1340 }
1341
1342 #[test]
1343 fn active_seats_flags_a_leftover_from_a_dead_process() {
1344 let _guard = plain();
1345 let mut s = state();
1346 s.seat_started("implement", "impl-B", std::time::Duration::from_secs(60), 0);
1347 let text = active_seats(&s, false);
1348 assert!(
1349 text.contains("no live daemon"),
1350 "a stale entry must not read as running: {text}"
1351 );
1352 }
1353
1354 #[test]
1355 fn active_seats_is_empty_when_nothing_is_running() {
1356 let _guard = plain();
1357 assert_eq!(active_seats(&state(), true), "");
1358 }
1359
1360 #[test]
1361 fn stats_table_renders_without_runs() {
1362 let _guard = plain();
1363 let text = stats(&Stats::default());
1364 assert!(text.contains("0 total"));
1365 assert!(!text.contains("implementation"));
1366 }
1367}