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