1use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::agent::{self, AgentOutput, Invocation, SeatState};
30use crate::ask;
31use crate::blind;
32use crate::bump;
33use crate::config::{
34 AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
35 ResolvedRoles,
36};
37use crate::git;
38use crate::land;
39use crate::proc::Quiet as _;
40use crate::prompt::{
41 self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
42};
43use crate::run::{
44 BaseSync, Candidate, CommandOutcome, DeliberationRound, DeliberationTurn, FixRecord, Judgement,
45 MergeOutcome, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus,
46 Tally, VoteRecord, tail, write_artifact,
47};
48use crate::verdict::{
49 self, FinalVote, FixReport, Position, Ranking, Review, ReviewRevote, ReviewVote, Severity,
50};
51
52const OUTPUT_TAIL: usize = 8_000;
54
55const EVENT_OUTPUT_TAIL: usize = 2_000;
58
59pub(crate) const STAGNANT_LIMIT: usize = 2;
73
74const BASE_SYNC_ROUNDS: usize = 4;
87
88#[derive(Clone)]
94struct SeatJob {
95 spec: AgentSpec,
96 seat: SeatState,
97 cwd: PathBuf,
98 prompt: String,
99 timeout: Duration,
100 allow_write: bool,
101 sessions: bool,
102 artifacts: PathBuf,
103 stem: String,
104}
105
106enum AgentOutcome {
118 Ok(AgentOutput),
120 Quota(AgentOutput),
122 Dropped(AgentOutput),
125 Failed(String),
127}
128
129#[derive(Debug, Clone, Default)]
146pub struct Pause(Arc<AtomicBool>);
147
148impl Pause {
149 #[must_use]
151 pub fn new() -> Self {
152 Self::default()
153 }
154
155 pub fn park(&self) {
157 self.0.store(true, Ordering::SeqCst);
158 }
159
160 #[must_use]
162 pub fn parked(&self) -> bool {
163 self.0.load(Ordering::SeqCst)
164 }
165}
166
167pub struct Runner {
169 pub state: RunState,
171 roles: ResolvedRoles,
172 sem: Arc<Semaphore>,
173 pause: Pause,
175}
176
177async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
197 let tracking = format!("{remote}/{base_branch}");
198 let fetched = git::fetch(repo, remote, base_branch).await;
199 if let Ok(out) = &fetched
200 && out.ok()
201 && git::rev_exists(repo, &tracking).await
202 {
203 return git::rev_parse(repo, &tracking).await;
204 }
205 let why = match &fetched {
206 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
207 Ok(_) => format!("{remote} has no {base_branch}"),
208 Err(e) => e.to_string(),
209 };
210 tracing::warn!(
211 "could not read {tracking} ({why}); branching off the local \
212 {base_branch} instead, which may be behind"
213 );
214 git::rev_parse(repo, base_branch).await.with_context(|| {
215 format!(
216 "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
217 branch that exists"
218 )
219 })
220}
221
222impl Runner {
223 pub async fn start(repo: &Path, instruction: String, config: Config) -> Result<Self> {
225 let repo = git::toplevel(repo).await?;
226 let missing = agent::missing_programs(&config.agents);
227 if !missing.is_empty() {
228 bail!(
229 "these agent programs are not on PATH: {}. Fix the roster in \
230 magi.toml or install them.",
231 missing.join(", ")
232 );
233 }
234 let base_branch = match config.merge.base.clone() {
235 Some(b) => b,
236 None => git::current_branch(&repo)
237 .await?
238 .context("HEAD is detached; set [merge] base in magi.toml")?,
239 };
240 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
241 if !git::is_clean(&repo).await? {
245 tracing::warn!(
246 "{} has uncommitted changes; they are not part of this run, \
247 which branches off {base_branch} ({})",
248 repo.display(),
249 &base_commit[..base_commit.len().min(8)]
250 );
251 }
252 let roles = config.resolve_roles()?;
253 let max_parallel = config.graph.max_parallel.max(1);
254 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
255 state.event("start", format!("run {} created", state.id));
256 state.save()?;
257 Ok(Self {
258 state,
259 roles,
260 sem: Arc::new(Semaphore::new(max_parallel)),
261 pause: Pause::new(),
262 })
263 }
264
265 pub async fn review(repo: &Path, branch: &str, config: Config) -> Result<Self> {
279 let repo = git::toplevel(repo).await?;
280 let missing = agent::missing_programs(&config.agents);
281 if !missing.is_empty() {
282 bail!(
283 "these agent programs are not on PATH: {}. Fix the roster in \
284 magi.toml or install them.",
285 missing.join(", ")
286 );
287 }
288 if !git::branch_exists(&repo, branch).await? {
289 bail!("no branch `{branch}` in {}", repo.display());
290 }
291 let base_branch = match config.merge.base.clone() {
292 Some(b) => b,
293 None => git::current_branch(&repo)
294 .await?
295 .context("HEAD is detached; set [merge] base in magi.toml")?,
296 };
297 if base_branch == branch {
298 bail!("`{branch}` is the base branch; there is nothing to review against");
299 }
300 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
301
302 let roles = config.resolve_roles()?;
303 let max_parallel = config.graph.max_parallel.max(1);
304 let log = git::log_oneline(&repo, &base_commit, branch)
307 .await
308 .unwrap_or_default();
309 let instruction = format!(
310 "Review the work already on branch `{branch}`. There is no task \
311 statement: what the change claims to do is whatever its commits \
312 say.\n\n{}",
313 if log.trim().is_empty() {
314 "(no commit messages)"
315 } else {
316 log.trim()
317 }
318 );
319 let mut state = RunState::new(
320 repo.clone(),
321 base_branch,
322 base_commit.clone(),
323 instruction,
324 config,
325 );
326
327 let worktree = state.worktree_root().join("under-review");
330 if let Some(parent) = worktree.parent() {
331 tokio::fs::create_dir_all(parent).await.ok();
332 }
333 let path = worktree.to_string_lossy().to_string();
334 git::git(&repo, &["worktree", "add", &path, branch])
335 .await
336 .with_context(|| {
337 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
338 })?;
339
340 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
341 .await
342 .unwrap_or(0);
343 if commits == 0 {
344 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
345 }
346 let files = git::changed_files(&worktree, &base_commit, "HEAD")
347 .await
348 .map(|f| f.len())
349 .unwrap_or(0);
350 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
351 .await
352 .unwrap_or_default();
353
354 state.candidates.push(Candidate {
355 index: 0,
356 label: 'A',
357 agent: "(existing branch)".to_owned(),
360 branch: branch.to_owned(),
361 worktree,
362 summary: String::new(),
363 stat,
364 files,
365 commits,
366 empty: false,
367 failed: None,
368 duration_ms: 0,
369 folded: false,
370 });
371 state.tally = Some(Tally {
372 first_choice: BTreeMap::from([('A', 0)]),
373 borda: BTreeMap::new(),
374 winner: 'A',
375 rankings: 0,
376 unanimous_initial: false,
377 deliberated: false,
378 changed_votes: 0,
379 unanimous_final: false,
380 tie_break: None,
381 judges: 0,
385 present: 0,
386 quorum: 0,
387 met_quorum: true,
388 uncontested: Some("review-only run: nothing competed".to_owned()),
389 });
390 state.status = RunStatus::Reviewing;
391 state.event(
392 "start",
393 format!(
394 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
395 state.id
396 ),
397 );
398 state.save()?;
399 Ok(Self {
400 state,
401 roles,
402 sem: Arc::new(Semaphore::new(max_parallel)),
403 pause: Pause::new(),
404 })
405 }
406
407 pub fn resume(id: &str) -> Result<Self> {
409 let state = RunState::load(id)?;
410 let roles = state.config.resolve_roles()?;
411 let max_parallel = state.config.graph.max_parallel.max(1);
412 Ok(Self {
413 state,
414 roles,
415 sem: Arc::new(Semaphore::new(max_parallel)),
416 pause: Pause::new(),
417 })
418 }
419
420 pub async fn execute(&mut self) -> Result<()> {
422 self.state.parked = false;
427 if self.state.clear_active() {
434 self.state.save()?;
435 }
436 if self.state.status == RunStatus::Stalled {
449 if self.recover_stall().await? {
450 self.finish_after_tally().await?;
451 } else {
452 self.state.save()?;
454 }
455 return Ok(());
456 }
457 if self.state.status == RunStatus::Landing {
467 self.run_land().await?;
468 self.settle_questions();
473 return Ok(());
474 }
475 self.prep().await?;
476 if self.park_here()? {
477 return Ok(());
478 }
479 self.implement().await?;
480 if self.park_here()? {
481 return Ok(());
482 }
483 self.judge().await?;
484 if self.park_here()? {
485 return Ok(());
486 }
487 self.deliberate().await?;
488 if self.park_here()? {
489 return Ok(());
490 }
491 self.vote().await?;
492 if self.park_here()? {
493 return Ok(());
494 }
495 self.tally()?;
496 if self.state.status == RunStatus::Stalled {
501 self.state.save()?;
505 return Ok(());
506 }
507 self.finish_after_tally().await?;
508 Ok(())
509 }
510
511 fn park_here(&mut self) -> Result<bool> {
518 if !self.pause.parked() {
519 return Ok(false);
520 }
521 self.state.event(
522 "park",
523 format!(
524 "parked after `{}` — resume to carry on from here",
525 self.state.status.as_str()
526 ),
527 );
528 self.state.parked = true;
529 self.state.save()?;
530 Ok(true)
531 }
532
533 pub fn on_pause(&mut self, pause: Pause) {
535 self.pause = pause;
536 }
537
538 fn settle_questions(&mut self) {
556 if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
557 tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
558 }
559 }
560
561 async fn finish_after_tally(&mut self) -> Result<()> {
564 self.fold_losers().await?;
565 self.sync_to_base().await?;
570 self.review_loop().await?;
571 self.sync_to_base().await?;
572 self.gate().await?;
573 self.merge().await?;
574 self.state.save()?;
575 Ok(())
576 }
577
578 async fn prep(&mut self) -> Result<()> {
581 if !self.state.candidates.is_empty() {
582 return Ok(());
583 }
584 self.state.status = RunStatus::Prep;
585 let repo = self.state.repo.clone();
586 let base = self.state.base_commit.clone();
587 let root = self.state.worktree_root();
588 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
589
590 let hooks_dir = self.state.dir().join("hooks");
593 if self.state.config.blind.commit_msg_hook {
594 std::fs::create_dir_all(&hooks_dir)
595 .with_context(|| format!("create {}", hooks_dir.display()))?;
596 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
597 let path = hooks_dir.join("commit-msg");
598 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
599 make_executable(&path)?;
600 git::acquire_worktree_config(&repo).await?;
608 self.state.enabled_worktree_config = true;
609 }
610
611 for (index, (spec, label)) in self
612 .roles
613 .implementers
614 .clone()
615 .into_iter()
616 .zip(labels)
617 .enumerate()
618 {
619 let branch = self.state.branch_for(label);
620 let worktree = root.join(format!("cand-{label}"));
621 git::worktree_add_branch(&repo, &worktree, &branch, &base).await?;
622 if self.state.config.blind.commit_msg_hook {
623 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
624 }
625 git::local_exclude(&worktree, "/.magi/").await?;
626 self.state.candidates.push(Candidate {
627 index,
628 label,
629 agent: spec.id.clone(),
630 branch,
631 worktree,
632 summary: String::new(),
633 stat: String::new(),
634 files: 0,
635 commits: 0,
636 empty: false,
637 failed: None,
638 duration_ms: 0,
639 folded: false,
640 });
641 }
642
643 for j in 1..=self.roles.judges.len() {
644 let wt = root.join(format!("judge-{j}"));
645 if !wt.exists() {
646 git::worktree_add_detached(&repo, &wt, &base).await?;
647 }
648 }
649
650 let authors: Vec<&str> = self
655 .roles
656 .implementers
657 .iter()
658 .map(|a| a.id.as_str())
659 .collect();
660 let overlap: Vec<String> = self
661 .roles
662 .judges
663 .iter()
664 .enumerate()
665 .filter(|(_, j)| authors.contains(&j.id.as_str()))
666 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
667 .collect();
668 if !overlap.is_empty() {
669 let note = format!(
670 "{} also authored a candidate; blind, but the panel is less \
671 independent than {} distinct agents would be",
672 overlap.join(", "),
673 self.roles.judges.len()
674 );
675 self.state.event("prep", note);
676 }
677
678 self.state.event(
679 "prep",
680 format!(
681 "{} candidates, {} judges, base {} ({})",
682 self.state.candidates.len(),
683 self.roles.judges.len(),
684 &self.state.base_commit[..7.min(self.state.base_commit.len())],
685 self.state.base_branch
686 ),
687 );
688 self.state.status = RunStatus::Implementing;
689 self.state.save()?;
690 Ok(())
691 }
692
693 async fn implement(&mut self) -> Result<()> {
696 let run_id = self.state.id.clone();
701 let prompts = self.state.config.prompts.clone();
702 let todo: Vec<usize> = self
703 .state
704 .candidates
705 .iter()
706 .enumerate()
707 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
708 .map(|(i, _)| i)
709 .collect();
710 if todo.is_empty() {
711 return self.after_implement();
712 }
713 self.state.status = RunStatus::Implementing;
714
715 let language = self.state.config.graph.language.clone();
716 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
717 let sessions = self.state.config.graph.sessions;
718 let artifacts = agent::artifacts_dir(&self.state.dir());
719
720 let mut jobs = Vec::new();
721 for &i in &todo {
722 let (index, label, worktree) = {
723 let c = &self.state.candidates[i];
724 (c.index, c.label, c.worktree.clone())
725 };
726 let spec = self.roles.implementers[index].clone();
727 let seat_key = format!("impl-{label}");
728 let seat = self.seat(&seat_key, &spec.id);
729 let instruction = self.state.instruction.clone();
730 jobs.push(SeatJob {
731 spec,
732 seat,
733 prompt: prompt::implement(&instruction, &worktree.to_string_lossy(), &language),
734 cwd: worktree,
735 timeout,
736 allow_write: true,
737 sessions,
738 artifacts: artifacts.clone(),
739 stem: format!("impl-{label}"),
740 });
741 }
742
743 self.state.event(
744 "implement",
745 format!("{} candidates in parallel", jobs.len()),
746 );
747 let sent = jobs.clone();
750 let cache = self.state.config.cache_dir();
751 let ctx = WaveCtx {
752 run: &run_id,
753 node: "implement",
754 prompts: &prompts,
755 cache: cache.as_deref(),
756 };
757 let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
758 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
759 .await;
760
761 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
762 let seat_key = seat.key.clone();
763 self.state.seats.insert(seat.key.clone(), seat);
764 let label = self.state.candidates[i].label;
765 let worktree = self.state.candidates[i].worktree.clone();
766 let base = self.state.base_commit.clone();
767
768 let (summary, duration, failed) = match out {
769 AgentOutcome::Ok(o) => {
770 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
771 let failed = (!o.usable()).then(|| {
772 if o.timed_out {
773 "agent timed out".to_owned()
774 } else {
775 format!("agent exited with {:?}", o.exit_code)
776 }
777 });
778 (text, o.duration_ms, failed)
779 }
780 AgentOutcome::Dropped(o) => {
786 let why = o
787 .dropped
788 .as_ref()
789 .map(|d| d.why.as_str())
790 .unwrap_or("the CLI ended the stream without delivering its answer");
791 (
792 String::new(),
793 o.duration_ms,
794 Some(format!("the CLI dropped the stream ({why})")),
795 )
796 }
797 AgentOutcome::Quota(o) => {
798 self.state.quota.push(QuotaLoss {
799 seat: seat_key,
800 node: "implement".to_owned(),
801 at: Timestamp::now(),
802 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
803 });
804 (
805 String::new(),
806 o.duration_ms,
807 Some("rate limited (quota); produced no change".to_owned()),
808 )
809 }
810 AgentOutcome::Failed(e) => (String::new(), 0, Some(e)),
811 };
812
813 let rescued = git::commit_all(
816 &worktree,
817 &format!("magi: candidate {label} (uncommitted work)"),
818 )
819 .await
820 .unwrap_or(false);
821 let commits = git::commits_ahead(&worktree, &base, "HEAD")
822 .await
823 .unwrap_or(0);
824 let patch = git::diff(&worktree, &base, "HEAD")
825 .await
826 .unwrap_or_default();
827 let stat = git::diff_stat(&worktree, &base, "HEAD")
828 .await
829 .unwrap_or_default();
830 let files = git::changed_files(&worktree, &base, "HEAD")
831 .await
832 .map(|f| f.len())
833 .unwrap_or(0);
834 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
835
836 let c = &mut self.state.candidates[i];
837 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
838 c.stat = stat;
839 c.files = files;
840 c.commits = commits;
841 c.duration_ms = duration;
842 c.empty = commits == 0 || patch.trim().is_empty();
843 c.failed = match failed {
846 Some(_) if c.empty => failed,
847 _ => None,
848 };
849 let note = match (&c.failed, c.empty, rescued) {
850 (Some(e), _, _) => format!("candidate {label}: {e}"),
851 (None, true, _) => format!("candidate {label}: no change produced"),
852 (None, false, true) => {
853 format!(
854 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
855 )
856 }
857 (None, false, false) => {
858 format!("candidate {label}: {files} files, {commits} commits")
859 }
860 };
861 self.state.event("implement", note);
862 self.state.save()?;
863 }
864
865 self.after_implement()
866 }
867
868 async fn resume_undelivered(
896 &mut self,
897 results: &mut [(usize, SeatState, AgentOutcome)],
898 sent: &[SeatJob],
899 prompts: &Prompts,
900 run_id: &str,
901 ) {
902 for (wi, seat, out) in results.iter_mut() {
903 let Some(dropped) = (match &*out {
904 AgentOutcome::Dropped(o) => o.dropped.clone(),
905 _ => None,
906 }) else {
907 continue;
908 };
909 let Some(job) = sent.get(*wi) else { continue };
910 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
912 self.state.event(
913 "implement",
914 format!(
915 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
916 work is in the tree",
917 seat.key, dropped.output_tokens, dropped.why
918 ),
919 );
920 continue;
921 }
922 if !has_context(&job.spec, seat, job.sessions) {
930 self.state.event(
931 "implement",
932 format!(
933 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
934 is no session left to resume",
935 seat.key, dropped.output_tokens, dropped.why
936 ),
937 );
938 continue;
939 }
940 self.state.event(
941 "implement",
942 format!(
943 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
944 conversation",
945 seat.key, dropped.output_tokens, dropped.why
946 ),
947 );
948 let mut retry = job.clone();
949 retry.seat = seat.clone();
950 retry.prompt = prompt::resume_after_drop(&dropped.why);
951 retry.timeout = retry_budget(job.timeout, true);
952 retry.stem = format!("{}-resume", job.stem);
953 let cache = self.state.config.cache_dir();
954 let ctx = WaveCtx {
955 run: run_id,
956 node: "implement",
957 prompts,
958 cache: cache.as_deref(),
959 };
960 let (resumed_seat, resumed) =
961 run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
962 *seat = resumed_seat;
963 *out = resumed;
964 }
965 }
966
967 fn after_implement(&mut self) -> Result<()> {
968 if self.state.leaks.is_empty() {
970 let cfg = self.state.config.blind.clone();
971 let mut leaks = Vec::new();
972 for c in &self.state.candidates {
973 let Some(patch) =
974 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
975 else {
976 continue;
977 };
978 leaks.extend(blind::scan(
979 &format!("candidate {} patch", c.label),
980 &patch,
981 &cfg.vendor_tokens,
982 ));
983 }
984 if !leaks.is_empty() {
985 let summary = leaks
986 .iter()
987 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
988 .collect::<Vec<_>>()
989 .join(", ");
990 match cfg.on_leak {
991 LeakPolicy::Fail => {
992 self.state.status = RunStatus::Failed;
993 self.state
994 .event("blind", format!("vendor text in a patch: {summary}"));
995 self.state.leaks = leaks;
996 self.state.save()?;
997 self.settle_questions();
998 bail!(
999 "blind.on_leak = \"fail\" and vendor text reached a \
1000 judged patch: {summary}"
1001 );
1002 }
1003 LeakPolicy::Redact => self.state.event(
1004 "blind",
1005 format!("redacting vendor text for judging: {summary}"),
1006 ),
1007 LeakPolicy::Warn => self.state.event(
1008 "blind",
1009 format!("vendor text present in a judged patch (shown as-is): {summary}"),
1010 ),
1011 }
1012 self.state.leaks = leaks;
1013 }
1014 }
1015
1016 if self.state.viable().is_empty() {
1017 self.state.status = RunStatus::Failed;
1018 self.state.save()?;
1019 self.settle_questions();
1020 bail!("no candidate produced a change; nothing to judge");
1021 }
1022 self.state.status = RunStatus::Judging;
1023 self.state.save()?;
1024 Ok(())
1025 }
1026
1027 async fn judge(&mut self) -> Result<()> {
1030 let run_id = self.state.id.clone();
1035 let prompts = self.state.config.prompts.clone();
1036 if !self.state.judgements.is_empty() || self.state.judge_skipped {
1037 return Ok(());
1038 }
1039 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1040 if viable.len() == 1 {
1041 self.state.judge_skipped = true;
1048 self.state.event(
1049 "judge",
1050 format!(
1051 "only candidate {} produced a change; judging skipped",
1052 viable[0].label
1053 ),
1054 );
1055 self.state.save()?;
1056 return Ok(());
1057 }
1058 self.state.status = RunStatus::Judging;
1059
1060 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1061 let language = self.state.config.graph.language.clone();
1062 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1063 let sessions = self.state.config.graph.sessions;
1064 let artifacts = agent::artifacts_dir(&self.state.dir());
1065 let root = self.state.worktree_root();
1066 let base_short = short(&self.state.base_commit);
1067
1068 let mut jobs = Vec::new();
1069 let mut orders = Vec::new();
1070 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1071 let order = blind::presentation_order(viable.len(), j, self.state.seed);
1072 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1073 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
1074 let seat_key = format!("judge-{}", j + 1);
1075 let seat = self.seat(&seat_key, &spec.id);
1076 jobs.push(SeatJob {
1077 prompt: prompt::judge(
1078 &self.state.instruction,
1079 &views,
1080 self.roles.judges.len(),
1081 &base_short,
1082 &language,
1083 ),
1084 spec,
1085 seat,
1086 cwd: root.join(format!("judge-{}", j + 1)),
1087 timeout,
1088 allow_write: false,
1089 sessions,
1090 artifacts: artifacts.clone(),
1091 stem: format!("judge-{}", j + 1),
1092 });
1093 }
1094
1095 self.state.event(
1096 "judge",
1097 format!(
1098 "{} judges ranking {} candidates blind",
1099 jobs.len(),
1100 viable.len()
1101 ),
1102 );
1103 let labels_for_check = labels.clone();
1104 let mut quota_losses = Vec::new();
1105 let cache = self.state.config.cache_dir();
1106 let ctx = WaveCtx {
1107 run: &run_id,
1108 node: "judge",
1109 prompts: &prompts,
1110 cache: cache.as_deref(),
1111 };
1112 let results = ask_json_wave::<Ranking>(
1113 jobs,
1114 Arc::clone(&self.sem),
1115 self.state.config.graph.retries,
1116 &ctx,
1117 &mut quota_losses,
1118 &mut self.state,
1119 &move |r: &Ranking| r.validate(&labels_for_check),
1120 )
1121 .await;
1122 self.state.quota.extend(quota_losses);
1123
1124 for (j, (seat, res)) in results.into_iter().enumerate() {
1125 let agent_id = seat.agent.clone();
1126 self.state.seats.insert(seat.key.clone(), seat);
1127 let mut record = Judgement {
1128 judge: j + 1,
1129 seat: format!("judge-{}", j + 1),
1130 agent: agent_id,
1131 ranking: Vec::new(),
1132 reasons: BTreeMap::new(),
1133 confidence: None,
1134 order: orders[j].clone(),
1135 failed: None,
1136 duration_ms: 0,
1137 };
1138 match res {
1139 Ok((ranking, out)) => {
1140 record.ranking = ranking.normalized();
1141 record.reasons = ranking.reasons;
1142 record.confidence = ranking.confidence;
1143 record.duration_ms = out.duration_ms;
1144 self.state.event(
1145 "judge",
1146 format!(
1147 "judge {} ranked {}",
1148 j + 1,
1149 record.ranking.iter().collect::<String>()
1150 ),
1151 );
1152 }
1153 Err(e) => {
1154 record.failed = Some(e.to_string());
1155 self.state
1156 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
1157 }
1158 }
1159 self.state.judgements.push(record);
1160 self.state.save()?;
1161 }
1162 Ok(())
1163 }
1164
1165 async fn deliberate(&mut self) -> Result<()> {
1168 let run_id = self.state.id.clone();
1173 let prompts = self.state.config.prompts.clone();
1174 if !self.state.deliberation.is_empty() {
1175 return Ok(());
1176 }
1177 let tops: Vec<char> = self
1178 .state
1179 .judgements
1180 .iter()
1181 .filter_map(|j| j.ranking.first().copied())
1182 .collect();
1183 let rounds = self.state.config.graph.deliberate_rounds;
1184 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
1185 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
1186 self.state.event(
1187 "deliberate",
1188 format!("judges agreed on {} outright; no deliberation", tops[0]),
1189 );
1190 }
1191 self.state.status = RunStatus::Voting;
1192 self.state.save()?;
1193 return Ok(());
1194 }
1195
1196 self.state.status = RunStatus::Deliberating;
1197 self.state.event(
1198 "deliberate",
1199 format!(
1200 "split: first choices were {} — opening {rounds} round(s)",
1201 tops.iter().collect::<String>()
1202 ),
1203 );
1204
1205 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1206 let language = self.state.config.graph.language.clone();
1207 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1208 let sessions = self.state.config.graph.sessions;
1209 let artifacts = agent::artifacts_dir(&self.state.dir());
1210 let root = self.state.worktree_root();
1211 let base_short = short(&self.state.base_commit);
1212
1213 for round in 1..=rounds {
1217 let mut turns: Vec<DeliberationTurn> = Vec::new();
1218 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1219 if self.state.judgements[j].failed.is_some() {
1220 continue;
1221 }
1222 let seat_key = format!("judge-{}", j + 1);
1223 let mut seat = self.seat(&seat_key, &spec.id);
1224 let transcript = self.transcript(&turns, j);
1225 let context = if has_context(&spec, &seat, sessions) {
1226 None
1227 } else {
1228 Some(self.candidate_block(&viable, &base_short))
1229 };
1230 let text = prompt::deliberate(
1231 &self.state.instruction,
1232 context.as_deref(),
1233 &transcript,
1234 round,
1235 rounds,
1236 &language,
1237 );
1238 let job = SeatJob {
1239 spec,
1240 seat: seat.clone(),
1241 prompt: text,
1242 cwd: root.join(format!("judge-{}", j + 1)),
1243 timeout,
1244 allow_write: false,
1245 sessions,
1246 artifacts: artifacts.clone(),
1247 stem: format!("delib-{round}-judge-{}", j + 1),
1248 };
1249 let cache = self.state.config.cache_dir();
1250 let ctx = WaveCtx {
1251 run: &run_id,
1252 node: "deliberate",
1253 prompts: &prompts,
1254 cache: cache.as_deref(),
1255 };
1256 let (updated, out) =
1257 run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1258 seat = updated;
1259 let agent_id = seat.agent.clone();
1260 let seat_key = seat.key.clone();
1261 self.state.seats.insert(seat.key.clone(), seat);
1262 let body = match out {
1263 AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
1264 AgentOutcome::Dropped(o) => {
1268 let why =
1269 o.dropped.as_ref().map(|d| d.why.as_str()).unwrap_or(
1270 "the CLI ended the stream without delivering its answer",
1271 );
1272 self.state.event(
1273 "deliberate",
1274 format!(
1275 "judge {} skipped: the CLI dropped the stream ({why})",
1276 j + 1
1277 ),
1278 );
1279 continue;
1280 }
1281 AgentOutcome::Quota(o) => {
1282 self.state.quota.push(QuotaLoss {
1283 seat: seat_key,
1284 node: "deliberate".to_owned(),
1285 at: Timestamp::now(),
1286 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1287 });
1288 self.state.event(
1289 "deliberate",
1290 format!("judge {} skipped: rate limited (quota)", j + 1),
1291 );
1292 continue;
1293 }
1294 AgentOutcome::Failed(e) => {
1295 self.state
1296 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
1297 continue;
1298 }
1299 };
1300 let tentative = verdict::extract_json::<Position>(&body)
1301 .ok()
1302 .and_then(|p| p.tentative)
1303 .and_then(|s| s.trim().chars().next())
1304 .map(|c| c.to_ascii_uppercase());
1305 self.state.event(
1306 "deliberate",
1307 format!(
1308 "round {round}: judge {} now favours {}",
1309 j + 1,
1310 tentative.map_or("—".to_owned(), |c| c.to_string())
1311 ),
1312 );
1313 turns.push(DeliberationTurn {
1314 judge: j + 1,
1315 agent: agent_id,
1316 body: blind::sanitize_prose(&body, &self.state.config.blind),
1317 tentative,
1318 });
1319 }
1320 self.state
1321 .deliberation
1322 .push(DeliberationRound { round, turns });
1323 self.state.save()?;
1324 }
1325
1326 self.state.status = RunStatus::Voting;
1327 self.state.save()?;
1328 Ok(())
1329 }
1330
1331 async fn vote(&mut self) -> Result<()> {
1334 let run_id = self.state.id.clone();
1339 let prompts = self.state.config.prompts.clone();
1340 if !self.state.votes.is_empty() {
1341 return Ok(());
1342 }
1343 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1344 if viable.len() == 1 {
1345 return Ok(());
1346 }
1347 self.state.status = RunStatus::Voting;
1348
1349 let language = self.state.config.graph.language.clone();
1350 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1351 let sessions = self.state.config.graph.sessions;
1352 let artifacts = agent::artifacts_dir(&self.state.dir());
1353 let root = self.state.worktree_root();
1354 let base_short = short(&self.state.base_commit);
1355 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1356
1357 let mut jobs = Vec::new();
1358 let mut seats_at = Vec::new();
1359 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1360 if self
1361 .state
1362 .judgements
1363 .get(j)
1364 .is_some_and(|r| r.failed.is_some())
1365 {
1366 continue;
1367 }
1368 let seat_key = format!("judge-{}", j + 1);
1369 let seat = self.seat(&seat_key, &spec.id);
1370 let mut text = prompt::final_vote(&viable, &language);
1371 if !has_context(&spec, &seat, sessions) {
1372 text = format!(
1373 "{}\n\n# Candidates\n\n{}",
1374 text,
1375 self.candidate_block(&candidates, &base_short)
1376 );
1377 }
1378 jobs.push(SeatJob {
1379 spec,
1380 seat,
1381 prompt: text,
1382 cwd: root.join(format!("judge-{}", j + 1)),
1383 timeout,
1384 allow_write: false,
1385 sessions,
1386 artifacts: artifacts.clone(),
1387 stem: format!("vote-judge-{}", j + 1),
1388 });
1389 seats_at.push(j);
1390 }
1391
1392 self.state.event(
1393 "vote",
1394 format!(
1395 "collecting {} final votes one by one, privately",
1396 jobs.len()
1397 ),
1398 );
1399 let allowed = viable.clone();
1400 let mut quota_losses = Vec::new();
1401 let cache = self.state.config.cache_dir();
1402 let ctx = WaveCtx {
1403 run: &run_id,
1404 node: "vote",
1405 prompts: &prompts,
1406 cache: cache.as_deref(),
1407 };
1408 let results = ask_json_wave::<FinalVote>(
1409 jobs,
1410 Arc::clone(&self.sem),
1411 self.state.config.graph.retries,
1412 &ctx,
1413 &mut quota_losses,
1414 &mut self.state,
1415 &move |v: &FinalVote| match v.label() {
1416 Some(c) if allowed.contains(&c) => Ok(()),
1417 other => bail!("vote {other:?} is not one of {allowed:?}"),
1418 },
1419 )
1420 .await;
1421 self.state.quota.extend(quota_losses);
1422
1423 for (&j, (seat, res)) in seats_at.iter().zip(results) {
1424 let agent_id = seat.agent.clone();
1425 self.state.seats.insert(seat.key.clone(), seat);
1426 let initial = self
1427 .state
1428 .judgements
1429 .get(j)
1430 .and_then(|r| r.ranking.first().copied());
1431 let mut record = VoteRecord {
1432 judge: j + 1,
1433 agent: agent_id,
1434 vote: None,
1435 reason: String::new(),
1436 changed: false,
1437 };
1438 match res {
1439 Ok((v, _)) => {
1440 record.vote = v.label();
1441 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1442 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
1443 self.state.event(
1444 "vote",
1445 format!(
1446 "judge {} voted {}{}",
1447 j + 1,
1448 record.vote.unwrap_or('?'),
1449 if record.changed { " (changed)" } else { "" }
1450 ),
1451 );
1452 }
1453 Err(e) => {
1454 self.state
1455 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
1456 }
1457 }
1458 self.state.votes.push(record);
1459 self.state.save()?;
1460 }
1461 Ok(())
1462 }
1463
1464 fn tally(&mut self) -> Result<()> {
1467 if self.state.tally.is_some() {
1468 return Ok(());
1469 }
1470 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1471 let tops: Vec<char> = self
1472 .state
1473 .judgements
1474 .iter()
1475 .filter_map(|j| j.ranking.first().copied())
1476 .collect();
1477 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
1478
1479 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1482 let mut cast: Vec<char> = Vec::new();
1483 for (i, j) in self.state.judgements.iter().enumerate() {
1484 let vote = self
1485 .state
1486 .votes
1487 .iter()
1488 .find(|v| v.judge == i + 1)
1489 .and_then(|v| v.vote)
1490 .or_else(|| j.ranking.first().copied());
1491 if let Some(v) = vote {
1492 *first_choice.entry(v).or_insert(0) += 1;
1493 cast.push(v);
1494 }
1495 }
1496
1497 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1498 for j in &self.state.judgements {
1499 let n = j.ranking.len();
1500 for (pos, label) in j.ranking.iter().enumerate() {
1501 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
1502 }
1503 }
1504
1505 let best = first_choice.values().copied().max().unwrap_or(0);
1506 let mut leaders: Vec<char> = first_choice
1507 .iter()
1508 .filter(|(_, v)| **v == best)
1509 .map(|(k, _)| *k)
1510 .collect();
1511 let mut tie_break = None;
1512 if leaders.len() > 1 {
1513 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
1514 let borda_leaders: Vec<char> = leaders
1515 .iter()
1516 .copied()
1517 .filter(|l| borda[l] == top_borda)
1518 .collect();
1519 tie_break = Some(if borda_leaders.len() == 1 {
1520 format!(
1521 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
1522 leaders.len()
1523 )
1524 } else {
1525 format!(
1526 "{} way tie on both first-choice votes and Borda points, broken by label order",
1527 leaders.len()
1528 )
1529 });
1530 leaders = borda_leaders;
1531 leaders.sort_unstable();
1532 }
1533 let winner = *leaders
1534 .first()
1535 .or(viable.first())
1536 .context("no candidate to declare a winner from")?;
1537
1538 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
1539 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
1540 let deliberated = !self.state.deliberation.is_empty();
1541
1542 let quota_seats: std::collections::BTreeSet<&str> =
1546 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1547 let mut present = 0usize;
1548 for (i, j) in self.state.judgements.iter().enumerate() {
1549 if quota_seats.contains(j.seat.as_str()) {
1550 continue;
1551 }
1552 let ranked = !j.ranking.is_empty() && j.failed.is_none();
1553 let voted = self
1554 .state
1555 .votes
1556 .iter()
1557 .any(|v| v.judge == i + 1 && v.vote.is_some());
1558 if ranked || voted {
1559 present += 1;
1560 }
1561 }
1562 let needs_quorum = viable.len() > 1;
1568 let judges_total = if needs_quorum {
1569 self.roles.judges.len()
1570 } else {
1571 0
1572 };
1573 let quorum = if needs_quorum {
1574 judges_total / 2 + 1
1575 } else {
1576 0
1577 };
1578 let met_quorum = !needs_quorum || present >= quorum;
1579 let uncontested = (!needs_quorum).then(|| {
1580 format!("only one candidate ({winner}) produced a usable change; no panel was asked")
1581 });
1582
1583 self.state.event(
1584 "tally",
1585 match &uncontested {
1586 Some(reason) => format!("winner {winner} — {reason}"),
1587 None => format!(
1588 "winner {winner} — votes {} | initial {} | {} changed | \
1589 {present}/{judges_total} judges{}",
1590 first_choice
1591 .iter()
1592 .map(|(k, v)| format!("{k}:{v}"))
1593 .collect::<Vec<_>>()
1594 .join(" "),
1595 if unanimous_initial {
1596 "unanimous"
1597 } else {
1598 "split"
1599 },
1600 changed_votes,
1601 if met_quorum {
1602 String::new()
1603 } else {
1604 format!(" — below quorum ({quorum} required)")
1605 },
1606 ),
1607 },
1608 );
1609 if !met_quorum {
1610 self.state.event(
1611 "stall",
1612 format!(
1613 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
1614 the run stops here, resumable"
1615 ),
1616 );
1617 }
1618 self.state.tally = Some(Tally {
1619 first_choice,
1620 borda,
1621 winner,
1622 rankings: tops.len(),
1623 unanimous_initial,
1624 deliberated,
1625 changed_votes,
1626 unanimous_final,
1627 tie_break,
1628 judges: judges_total,
1629 present,
1630 quorum,
1631 met_quorum,
1632 uncontested,
1633 });
1634 self.state.status = if met_quorum {
1635 RunStatus::Reviewing
1636 } else {
1637 RunStatus::Stalled
1638 };
1639 self.state.save()?;
1640 Ok(())
1641 }
1642
1643 #[allow(clippy::too_many_lines)]
1664 async fn recover_stall(&mut self) -> Result<bool> {
1665 let run_id = self.state.id.clone();
1670 let prompts = self.state.config.prompts.clone();
1671 let quota_seats: BTreeSet<&str> =
1676 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1677 let absent: Vec<String> = self
1678 .state
1679 .judgements
1680 .iter()
1681 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
1682 .map(|j| j.seat.clone())
1683 .collect();
1684 if absent.is_empty() {
1685 return Ok(false);
1686 }
1687 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1688 if viable.len() <= 1 {
1689 return Ok(false);
1690 }
1691 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1692 let language = self.state.config.graph.language.clone();
1693 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1694 let sessions = self.state.config.graph.sessions;
1695 let artifacts = agent::artifacts_dir(&self.state.dir());
1696 let root = self.state.worktree_root();
1697 let base_short = short(&self.state.base_commit);
1698 let candidates: Vec<Candidate> = viable.clone();
1699
1700 let mut positions: Vec<usize> = absent
1702 .iter()
1703 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
1704 .collect();
1705 if positions.is_empty() {
1706 return Ok(false);
1707 }
1708 positions.sort_unstable();
1709 positions.dedup();
1710
1711 let mut judge_jobs = Vec::new();
1713 for &j in &positions {
1714 let order = blind::presentation_order(viable.len(), j, self.state.seed);
1715 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1716 let seat_key = format!("judge-{}", j + 1);
1717 let spec = self.roles.judges[j].clone();
1718 let seat = self.seat(&seat_key, &spec.id);
1719 judge_jobs.push(SeatJob {
1720 spec,
1721 seat,
1722 prompt: prompt::judge(
1723 &self.state.instruction,
1724 &views,
1725 self.roles.judges.len(),
1726 &base_short,
1727 &language,
1728 ),
1729 cwd: root.join(seat_key),
1730 timeout,
1731 allow_write: false,
1732 sessions,
1733 artifacts: artifacts.clone(),
1734 stem: format!("judge-{}-recover", j + 1),
1735 });
1736 }
1737
1738 let labels_for_check = labels.clone();
1739 let mut judge_losses = Vec::new();
1740 let retries = self.state.config.graph.retries;
1741 let cache = self.state.config.cache_dir();
1742 let ctx = WaveCtx {
1743 run: &run_id,
1744 node: "judge",
1745 prompts: &prompts,
1746 cache: cache.as_deref(),
1747 };
1748 let results = ask_json_wave::<Ranking>(
1749 judge_jobs,
1750 Arc::clone(&self.sem),
1751 retries,
1752 &ctx,
1753 &mut judge_losses,
1754 &mut self.state,
1755 &move |r: &Ranking| r.validate(&labels_for_check),
1756 )
1757 .await;
1758
1759 let mut recovered: BTreeSet<usize> = BTreeSet::new();
1761 for (&j, (seat, res)) in positions.iter().zip(results) {
1762 self.state.seats.insert(seat.key.clone(), seat);
1763 let record = &mut self.state.judgements[j];
1764 match res {
1765 Ok((ranking, out)) => {
1766 record.ranking = ranking.normalized();
1767 record.reasons = ranking.reasons;
1768 record.confidence = ranking.confidence;
1769 record.failed = None;
1770 record.duration_ms = out.duration_ms;
1771 recovered.insert(j);
1772 self.state.event(
1773 "recover",
1774 format!("judge {} ranked again after the limit", j + 1),
1775 );
1776 }
1777 Err(e) => {
1778 self.state
1779 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
1780 }
1781 }
1782 }
1783
1784 let mut vote_jobs = Vec::new();
1786 let mut vote_pos: Vec<usize> = Vec::new();
1787 for &j in &recovered {
1788 let seat_key = format!("judge-{}", j + 1);
1789 let spec = self.roles.judges[j].clone();
1790 let seat = self.seat(&seat_key, &spec.id);
1791 let mut text = prompt::final_vote(&labels, &language);
1792 if !has_context(&spec, &seat, sessions) {
1793 text = format!(
1794 "{}\n\n# Candidates\n\n{}",
1795 text,
1796 self.candidate_block(&candidates, &base_short)
1797 );
1798 }
1799 vote_jobs.push(SeatJob {
1800 spec,
1801 seat,
1802 prompt: text,
1803 cwd: root.join(seat_key),
1804 timeout,
1805 allow_write: false,
1806 sessions,
1807 artifacts: artifacts.clone(),
1808 stem: format!("vote-judge-{}-recover", j + 1),
1809 });
1810 vote_pos.push(j);
1811 }
1812 let allowed = labels.clone();
1813 let mut vote_losses = Vec::new();
1814 let vote_retries = self.state.config.graph.retries;
1815 let vote_cache = self.state.config.cache_dir();
1816 let ctx = WaveCtx {
1817 run: &run_id,
1818 node: "vote",
1819 prompts: &prompts,
1820 cache: vote_cache.as_deref(),
1821 };
1822 let votes = ask_json_wave::<FinalVote>(
1823 vote_jobs,
1824 Arc::clone(&self.sem),
1825 vote_retries,
1826 &ctx,
1827 &mut vote_losses,
1828 &mut self.state,
1829 &move |v: &FinalVote| match v.label() {
1830 Some(c) if allowed.contains(&c) => Ok(()),
1831 other => bail!("vote {other:?} is not one of {allowed:?}"),
1832 },
1833 )
1834 .await;
1835 for (&j, (seat, res)) in vote_pos.iter().zip(votes) {
1836 let agent_id = seat.agent.clone();
1837 self.state.seats.insert(seat.key.clone(), seat);
1838 match res {
1839 Ok((v, _)) => {
1840 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
1841 rec.vote = v.label();
1842 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1843 } else {
1844 self.state.votes.push(VoteRecord {
1845 judge: j + 1,
1846 agent: agent_id,
1847 vote: v.label(),
1848 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
1849 changed: false,
1850 });
1851 }
1852 self.state.event(
1853 "recover",
1854 format!("judge {} voted again after the limit", j + 1),
1855 );
1856 }
1857 Err(e) => {
1858 self.state
1859 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
1860 }
1861 }
1862 }
1863
1864 if !recovered.is_empty() {
1868 let recovered_keys: BTreeSet<String> = recovered
1869 .iter()
1870 .map(|&j| format!("judge-{}", j + 1))
1871 .collect();
1872 self.state
1873 .quota
1874 .retain(|q| !recovered_keys.contains(&q.seat));
1875 }
1876
1877 self.state.tally = None;
1879 self.tally()?;
1880 Ok(self
1881 .state
1882 .tally
1883 .as_ref()
1884 .map(|t| t.met_quorum)
1885 .unwrap_or(false))
1886 }
1887
1888 async fn fold_losers(&mut self) -> Result<()> {
1891 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
1892 return Ok(());
1893 };
1894 let repo = self.state.repo.clone();
1895 let mut folded = Vec::new();
1896 for i in 0..self.state.candidates.len() {
1897 let c = &self.state.candidates[i];
1898 if c.label == winner || c.folded {
1899 continue;
1900 }
1901 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
1902 git::worktree_remove(&repo, &wt).await.ok();
1903 git::branch_delete(&repo, &branch).await.ok();
1904 self.state.candidates[i].folded = true;
1905 folded.push(label.to_string());
1906 }
1907 let root = self.state.worktree_root();
1909 for j in 1..=self.roles.judges.len() {
1910 let wt = root.join(format!("judge-{j}"));
1911 if wt.exists() {
1912 git::worktree_remove(&repo, &wt).await.ok();
1913 }
1914 }
1915 if !folded.is_empty() {
1916 self.state
1917 .event("fold", format!("folded candidates {}", folded.join(", ")));
1918 self.state.save()?;
1919 }
1920 Ok(())
1921 }
1922
1923 async fn sync_to_base(&mut self) -> Result<()> {
1953 if self
1954 .state
1955 .base_sync
1956 .as_ref()
1957 .is_some_and(|s| s.conflict.is_some())
1958 {
1959 return Ok(());
1960 }
1961 let Some(winner) = self.state.winner().cloned() else {
1962 return Ok(());
1963 };
1964
1965 let repo = self.state.repo.clone();
1966 let remote = self.state.config.merge.remote.clone();
1967 let base_branch = self.state.base_branch.clone();
1968 let tracking = format!("{remote}/{base_branch}");
1969
1970 git::fetch(&repo, &remote, &base_branch).await.ok();
1971 let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
1975 return Ok(());
1976 };
1977
1978 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
1979 let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
1980 let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
1981
1982 if behind == 0 {
1983 self.state.base_sync = Some(BaseSync {
1984 tip,
1985 behind: 0,
1986 attempts,
1987 conflict: None,
1988 });
1989 self.state.save()?;
1990 return Ok(());
1991 }
1992
1993 if attempts >= BASE_SYNC_ROUNDS {
1994 let why = format!(
1995 "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
1996 rebase(s); rebasing again would only race it",
1997 winner.branch
1998 );
1999 self.state.status = RunStatus::Blocked;
2000 self.state.base_sync = Some(BaseSync {
2001 tip,
2002 behind,
2003 attempts,
2004 conflict: Some(why.clone()),
2005 });
2006 self.state.event("land", why);
2007 self.state.save()?;
2008 return Ok(());
2009 }
2010
2011 self.state.event(
2012 "land",
2013 format!(
2014 "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
2015 winner.branch
2016 ),
2017 );
2018 self.state.save()?;
2019
2020 let scratch = self.state.dir().join("base-sync");
2021 let rebased = git::rebase_branch_in_temp(&repo, &scratch, &winner.branch, &tracking).await;
2022 let attempts = attempts + 1;
2023 match rebased {
2024 Ok(None) => {
2025 git::sync_to_head(&winner.worktree).await?;
2029 self.state.base_sync = Some(BaseSync {
2030 tip: tip.clone(),
2031 behind: 0,
2032 attempts,
2033 conflict: None,
2034 });
2035 self.state
2036 .event("land", format!("rebased {} onto {tracking}", winner.branch));
2037 }
2038 Ok(Some(conflict)) => {
2039 let why = format!(
2040 "{} conflicts with {tracking} and did not rebase: {}",
2041 winner.branch,
2042 conflict.chars().take(600).collect::<String>()
2043 );
2044 self.state.status = RunStatus::Blocked;
2045 self.state.base_sync = Some(BaseSync {
2046 tip,
2047 behind,
2048 attempts,
2049 conflict: Some(why.clone()),
2050 });
2051 self.state.event("land", why);
2052 }
2053 Err(e) => {
2054 let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
2055 self.state.status = RunStatus::Blocked;
2056 self.state.base_sync = Some(BaseSync {
2057 tip,
2058 behind,
2059 attempts,
2060 conflict: Some(why.clone()),
2061 });
2062 self.state.event("land", why);
2063 }
2064 }
2065 self.state.save()?;
2066 Ok(())
2067 }
2068
2069 fn landing_base(&self) -> String {
2079 self.state
2080 .base_sync
2081 .as_ref()
2082 .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
2083 }
2084
2085 async fn review_loop(&mut self) -> Result<()> {
2088 if self
2093 .state
2094 .base_sync
2095 .as_ref()
2096 .is_some_and(|s| s.conflict.is_some())
2097 {
2098 return Ok(());
2099 }
2100 let run_id = self.state.id.clone();
2105 let prompts = self.state.config.prompts.clone();
2106 let Some(winner) = self.state.winner().cloned() else {
2107 return Ok(());
2108 };
2109 let max_rounds = self.state.config.graph.review_rounds;
2110 if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
2120 self.state.status = status;
2121 self.state.save()?;
2122 return Ok(());
2123 }
2124 self.state.status = RunStatus::Reviewing;
2125
2126 let repo = self.state.repo.clone();
2127 let root = self.state.worktree_root();
2128 let language = self.state.config.graph.language.clone();
2129 let sessions = self.state.config.graph.sessions;
2130 let artifacts = agent::artifacts_dir(&self.state.dir());
2131 let base = self.landing_base();
2132 let base_short = short(&base);
2133 let reviewers = self.roles.reviewers.clone();
2134 let shell = self.state.config.shell();
2135
2136 let mut prev_e2e: Option<String> = None;
2137 for round in (self.state.reviews.len() + 1)..=max_rounds {
2138 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
2139 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
2140 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
2141
2142 let mut jobs = Vec::new();
2146 for (r, spec) in reviewers.iter().cloned().enumerate() {
2147 let wt = root.join(format!("review-{}", r + 1));
2148 if wt.exists() {
2149 git::reset_detached(&wt, &head).await?;
2150 } else {
2151 git::worktree_add_detached(&repo, &wt, &head).await?;
2152 }
2153 let seat_key = format!("review-{}", r + 1);
2154 let seat = self.seat(&seat_key, &spec.id);
2155 jobs.push(SeatJob {
2156 prompt: prompt::review(&prompt::ReviewCtx {
2157 instruction: &self.state.instruction,
2158 branch: &winner.branch,
2159 base_short: &base_short,
2160 stat: &stat,
2161 patch: &patch,
2162 e2e: prev_e2e.as_deref(),
2163 reviewers: reviewers.len(),
2164 round,
2165 rounds: max_rounds,
2166 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
2169 lens: Lens::for_seat(r),
2170 language: &language,
2171 }),
2172 spec,
2173 seat,
2174 cwd: wt,
2175 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
2176 allow_write: false,
2177 sessions,
2178 artifacts: artifacts.clone(),
2179 stem: format!("review-{round}-{}", r + 1),
2180 });
2181 }
2182
2183 self.state.event(
2184 "review",
2185 format!(
2186 "round {round}: {} reviewers on {}",
2187 jobs.len(),
2188 short(&head)
2189 ),
2190 );
2191 let mut quota_losses = Vec::new();
2192 let review_retries = self.state.config.graph.retries;
2193 let review_cache = self.state.config.cache_dir();
2194 let ctx = WaveCtx {
2195 run: &run_id,
2196 node: "review",
2197 prompts: &prompts,
2198 cache: review_cache.as_deref(),
2199 };
2200 let results = ask_json_wave::<Review>(
2201 jobs,
2202 Arc::clone(&self.sem),
2203 review_retries,
2204 &ctx,
2205 &mut quota_losses,
2206 &mut self.state,
2207 &|_: &Review| Ok(()),
2208 )
2209 .await;
2210 let round_quota_missing = quota_losses.len();
2214 self.state.quota.extend(quota_losses);
2215
2216 let mut records = Vec::new();
2217 let mut all_findings = Vec::new();
2218 for (r, (seat, res)) in results.into_iter().enumerate() {
2219 let agent_id = seat.agent.clone();
2220 self.state.seats.insert(seat.key.clone(), seat);
2221 let mut record = ReviewRecord {
2222 reviewer: r + 1,
2223 agent: agent_id,
2224 summary: String::new(),
2225 findings: Vec::new(),
2226 vote: None,
2227 failed: None,
2228 duration_ms: 0,
2229 };
2230 match res {
2231 Ok((review, out)) => {
2232 record.summary =
2240 blind::sanitize_prose(&review.summary, &self.state.config.blind);
2241 record.vote = Some(review.vote);
2242 record.duration_ms = out.duration_ms;
2243 for (n, mut f) in review.findings.into_iter().enumerate() {
2244 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
2247 f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
2248 f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
2249 f.file = f
2255 .file
2256 .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
2257 all_findings.push(f.clone());
2258 record.findings.push(f);
2259 }
2260 self.state.event(
2261 "review",
2262 format!(
2263 "round {round}: reviewer {} voted {} with {} finding(s)",
2264 r + 1,
2265 review.vote.label(),
2266 record.findings.len()
2267 ),
2268 );
2269 }
2270 Err(e) => {
2271 record.failed = Some(e.to_string());
2272 self.state.event(
2273 "review",
2274 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
2275 );
2276 }
2277 }
2278 records.push(record);
2279 }
2280
2281 let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
2288 let vote_split =
2289 initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
2290 let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
2291 if vote_split {
2292 self.state.event(
2293 "review",
2294 format!(
2295 "round {round}: votes split ({}) — one round of reconsideration",
2296 initial_votes
2297 .iter()
2298 .map(|v| v.label())
2299 .collect::<Vec<_>>()
2300 .join(", ")
2301 ),
2302 );
2303 let panel: Vec<ReviewSeatReport<'_>> = records
2306 .iter()
2307 .filter_map(|r| {
2308 r.vote.map(|vote| ReviewSeatReport {
2309 reviewer: r.reviewer,
2310 vote,
2311 summary: &r.summary,
2312 findings: &r.findings,
2313 })
2314 })
2315 .collect();
2316
2317 let mut jobs = Vec::new();
2318 let mut seats_at = Vec::new();
2319 for (r, spec) in reviewers.iter().cloned().enumerate() {
2320 if records[r].vote.is_none() {
2324 continue;
2325 }
2326 let wt = root.join(format!("review-{}", r + 1));
2327 let seat_key = format!("review-{}", r + 1);
2328 let seat = self.seat(&seat_key, &spec.id);
2329 let patch_ctx = if has_context(&spec, &seat, sessions) {
2334 None
2335 } else {
2336 Some(ReviewPatch {
2337 branch: &winner.branch,
2338 base_short: &base_short,
2339 stat: &stat,
2340 patch: &patch,
2341 })
2342 };
2343 let prompt = prompt::review_reconsider(&ReviewReconsiderCtx {
2344 instruction: &self.state.instruction,
2345 reviewer: r + 1,
2346 lens: Lens::for_seat(r),
2347 panel: &panel,
2348 patch: patch_ctx,
2349 round,
2350 rounds: max_rounds,
2351 language: &language,
2352 });
2353 jobs.push(SeatJob {
2354 prompt,
2355 spec,
2356 seat,
2357 cwd: wt,
2358 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
2359 allow_write: false,
2360 sessions,
2361 artifacts: artifacts.clone(),
2362 stem: format!("review-{round}-reconsider-{}", r + 1),
2363 });
2364 seats_at.push(r);
2365 }
2366
2367 let mut recon_quota_losses = Vec::new();
2368 let recon_cache = self.state.config.cache_dir();
2369 let recon_ctx = WaveCtx {
2370 run: &run_id,
2371 node: "review",
2372 prompts: &prompts,
2373 cache: recon_cache.as_deref(),
2374 };
2375 let recon_results = ask_json_wave::<ReviewRevote>(
2376 jobs,
2377 Arc::clone(&self.sem),
2378 review_retries,
2379 &recon_ctx,
2380 &mut recon_quota_losses,
2381 &mut self.state,
2382 &|_: &ReviewRevote| Ok(()),
2383 )
2384 .await;
2385 self.state.quota.extend(recon_quota_losses);
2386
2387 for (&r, (seat, res)) in seats_at.iter().zip(recon_results) {
2388 let agent_id = seat.agent.clone();
2389 self.state.seats.insert(seat.key.clone(), seat);
2390 let mut rec = ReviewRevoteRecord {
2391 reviewer: r + 1,
2392 agent: agent_id,
2393 vote: None,
2394 reason: String::new(),
2395 failed: None,
2396 };
2397 match res {
2398 Ok((rv, _)) => {
2399 rec.vote = Some(rv.vote);
2400 rec.reason =
2401 blind::sanitize_prose(&rv.reason, &self.state.config.blind);
2402 self.state.event(
2403 "review",
2404 format!(
2405 "round {round}: reviewer {} revoted {}",
2406 r + 1,
2407 rv.vote.label()
2408 ),
2409 );
2410 }
2411 Err(e) => {
2412 rec.failed = Some(e.to_string());
2413 self.state.event(
2414 "review",
2415 format!("round {round}: reviewer {} did not revote: {e}", r + 1),
2416 );
2417 }
2418 }
2419 reconsideration.push(rec);
2420 }
2421 } else if initial_votes.len() > 1 {
2422 self.state.event(
2423 "review",
2424 format!(
2425 "round {round}: votes agreed ({}) — no reconsideration",
2426 initial_votes[0].label()
2427 ),
2428 );
2429 }
2430
2431 let final_votes: Vec<ReviewVote> = records
2435 .iter()
2436 .filter_map(|r| {
2437 reconsideration
2438 .iter()
2439 .find(|rv| rv.reviewer == r.reviewer)
2440 .and_then(|rv| rv.vote)
2441 .or(r.vote)
2442 })
2443 .collect();
2444 let round_verdict = ReviewVote::worst(final_votes);
2445
2446 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
2447 let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
2448 let defer_e2e =
2459 blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
2460 let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
2461 let reason =
2462 format!("{blocking} blocking finding(s) already required a fix this round");
2463 self.state.event(
2464 "verify",
2465 format!(
2466 "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
2467 {}); it will run once a round has none left",
2468 short(&head)
2469 ),
2470 );
2471 (Vec::new(), false, true, Some(reason))
2472 } else {
2473 let e2e_commands = self.state.config.verify.e2e.clone();
2474 let (e2e, verify_retried) = run_e2e_with_retry(
2475 &mut self.state,
2476 &shell,
2477 &e2e_commands,
2478 &winner.worktree,
2479 verify_timeout,
2480 &format!("round {round}"),
2481 )
2482 .await;
2483 (e2e, verify_retried, false, None)
2484 };
2485
2486 let e2e_failures: String = e2e
2487 .iter()
2488 .filter(|o| !o.ok())
2489 .map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
2490 .collect();
2491
2492 let expected = records.len();
2493 let answered = records.iter().filter(|r| r.failed.is_none()).count();
2494 let incomplete = answered < expected;
2495 let e2e_ok = e2e.iter().all(CommandOutcome::ok);
2496 let policy = self.state.config.graph.incomplete_review;
2497 let clean = round_is_clean(
2498 blocking,
2499 e2e_ok,
2500 answered,
2501 expected,
2502 round_quota_missing,
2503 policy,
2504 );
2505
2506 let mut round_record = ReviewRound {
2507 round,
2508 head: head.clone(),
2509 verified_head: None,
2510 reviews: records,
2511 e2e,
2512 verify_retried,
2513 e2e_deferred,
2514 e2e_defer_reason,
2515 fix: None,
2516 blocking,
2517 answered,
2518 expected,
2519 clean,
2520 progressed: false,
2521 vote_split,
2522 reconsideration,
2523 verdict: round_verdict,
2524 };
2525
2526 if incomplete {
2527 let missing: Vec<String> = round_record
2528 .reviews
2529 .iter()
2530 .filter(|r| r.failed.is_some())
2531 .map(|r| format!("review-{}", r.reviewer))
2532 .collect();
2533 self.state.event(
2534 "review",
2535 format!(
2536 "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
2537 missing.join(", ")
2538 ),
2539 );
2540 }
2541
2542 if clean {
2543 self.state.event(
2544 "review",
2545 if incomplete && policy == IncompleteReviewPolicy::Warn {
2546 format!(
2547 "round {round}: clean (warn policy, incomplete panel) — no \
2548 blocking findings from the seats that answered, verification green"
2549 )
2550 } else if incomplete {
2551 format!(
2552 "round {round}: clean ({} rate-limited reviewer(s) excluded from \
2553 quorum) — no blocking findings from the seats that answered, \
2554 verification green",
2555 expected - answered
2556 )
2557 } else {
2558 format!("round {round}: clean — no blocking findings, verification green")
2559 },
2560 );
2561 self.state.reviews.push(round_record);
2562 self.state.status = RunStatus::Gating;
2563 self.state.save()?;
2564 return Ok(());
2565 }
2566
2567 if incomplete && blocking == 0 && e2e_ok {
2575 self.state.reviews.push(round_record);
2576 self.state.save()?;
2577 if round == max_rounds {
2578 self.state.status = RunStatus::Blocked;
2579 self.state.event(
2580 "review",
2581 format!(
2582 "{} reviewer seat(s) never answered after {max_rounds} rounds; \
2583 refusing to call it clean",
2584 expected - answered
2585 ),
2586 );
2587 return Ok(());
2588 }
2589 prev_e2e = None;
2590 continue;
2591 }
2592
2593 if round == max_rounds {
2594 self.state.reviews.push(round_record);
2595 return self
2596 .stop_reviewing(
2597 &format!(
2598 "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
2599 ),
2600 &shell,
2601 &winner.worktree,
2602 )
2603 .await;
2604 }
2605
2606 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
2609 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
2610 _ => (
2611 self.state
2612 .config
2613 .agent(&winner.agent)
2614 .cloned()
2615 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
2616 format!("impl-{}", winner.label),
2617 ),
2618 };
2619 let seat = self.seat(&fix_seat_key, &fix_spec.id);
2620 let blocking_findings: Vec<_> = all_findings
2621 .iter()
2622 .filter(|f| f.severity.blocks())
2623 .cloned()
2624 .collect();
2625 let job = SeatJob {
2626 prompt: prompt::fix(
2627 &self.state.instruction,
2628 &blocking_findings,
2629 (!e2e_failures.is_empty()).then_some(e2e_failures.as_str()),
2630 e2e_deferred,
2631 round,
2632 max_rounds,
2633 &language,
2634 ),
2635 spec: fix_spec.clone(),
2636 seat,
2637 cwd: winner.worktree.clone(),
2638 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
2639 allow_write: true,
2640 sessions,
2641 artifacts: artifacts.clone(),
2642 stem: format!("fix-{round}"),
2643 };
2644 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2645 let cache = self.state.config.cache_dir();
2646 let ctx = WaveCtx {
2647 run: &run_id,
2648 node: "fix",
2649 prompts: &prompts,
2650 cache: cache.as_deref(),
2651 };
2652 let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2653 let agent_id = seat.agent.clone();
2654 let seat_key = seat.key.clone();
2655 self.state.seats.insert(seat.key.clone(), seat);
2656
2657 let mut fix = FixRecord {
2658 agent: agent_id,
2659 addressed: Vec::new(),
2660 rejected: Vec::new(),
2661 notes: String::new(),
2662 committed: false,
2663 failed: None,
2664 duration_ms: 0,
2665 };
2666 match out {
2667 AgentOutcome::Ok(o) => {
2668 fix.duration_ms = o.duration_ms;
2669 match verdict::extract_json::<FixReport>(&o.text) {
2670 Ok(report) => {
2671 fix.addressed = report.addressed;
2672 fix.rejected = report.rejected;
2673 fix.notes =
2674 blind::sanitize_prose(&report.notes, &self.state.config.blind);
2675 }
2676 Err(e) => fix.failed = Some(format!("unparsable fix report: {e}")),
2677 }
2678 }
2679 AgentOutcome::Dropped(o) => {
2681 fix.duration_ms = o.duration_ms;
2682 let why = o
2683 .dropped
2684 .as_ref()
2685 .map(|d| d.why.as_str())
2686 .unwrap_or("the CLI ended the stream without delivering its answer");
2687 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
2688 }
2689 AgentOutcome::Quota(o) => {
2690 self.state.quota.push(QuotaLoss {
2691 seat: seat_key,
2692 node: "fix".to_owned(),
2693 at: Timestamp::now(),
2694 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2695 });
2696 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
2697 }
2698 AgentOutcome::Failed(e) => fix.failed = Some(e),
2699 }
2700 git::commit_all(
2701 &winner.worktree,
2702 &format!("magi: review round {round} fixes (uncommitted work)"),
2703 )
2704 .await
2705 .ok();
2706 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2707 fix.committed = after != before;
2708 let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
2716 let progressed = diff_after != patch;
2717 let commit_note = if fix.committed {
2718 "committed"
2719 } else {
2720 "NO new commit"
2721 };
2722 let tree_note = if progressed {
2723 "changed vs base"
2724 } else {
2725 "unchanged vs base"
2726 };
2727 self.state.event(
2728 "fix",
2729 match &fix.failed {
2730 Some(reason) => {
2736 format!(
2737 "round {round}: fixer's adoption report was lost ({reason}); \
2738 {commit_note}, tree {tree_note}"
2739 )
2740 }
2741 None => format!(
2742 "round {round}: {} addressed, {} rejected, {commit_note}, tree {tree_note}",
2743 fix.addressed.len(),
2744 fix.rejected.len(),
2745 ),
2746 },
2747 );
2748 round_record.fix = Some(fix);
2749 round_record.progressed = progressed;
2750 self.state.reviews.push(round_record);
2751 self.state.save()?;
2752
2753 prev_e2e = (!e2e_failures.is_empty()).then_some(e2e_failures);
2754
2755 let streak = self
2756 .state
2757 .reviews
2758 .iter()
2759 .rev()
2760 .take_while(|r| !r.progressed)
2761 .count();
2762 if streak >= STAGNANT_LIMIT {
2763 return self
2764 .stop_reviewing(
2765 &format!(
2766 "the tree has not moved against base for {streak} round(s) in a row"
2767 ),
2768 &shell,
2769 &winner.worktree,
2770 )
2771 .await;
2772 }
2773 }
2774 Ok(())
2775 }
2776
2777 async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
2801 let round_idx = self.state.reviews.len() - 1;
2802 let needs_catchup_run = {
2803 let last = &self.state.reviews[round_idx];
2804 last.e2e.is_empty() && last.e2e_deferred
2805 };
2806 if needs_catchup_run {
2807 let round = self.state.reviews[round_idx].round;
2808 let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
2809 let commands = self.state.config.verify.e2e.clone();
2810 let verified_head = git::rev_parse(worktree, "HEAD").await?;
2811 let (outcomes, verify_retried) = run_e2e_with_retry(
2812 &mut self.state,
2813 shell,
2814 &commands,
2815 worktree,
2816 timeout,
2817 &format!("round {round}: deferred e2e, now catching up before the final decision"),
2818 )
2819 .await;
2820 let last = &mut self.state.reviews[round_idx];
2821 last.e2e = outcomes;
2822 last.verify_retried = verify_retried;
2823 last.e2e_deferred = false;
2824 if verified_head != last.head {
2825 last.verified_head = Some(verified_head);
2826 }
2827 }
2828 let last = &self.state.reviews[round_idx];
2829 let red: Vec<String> = last
2830 .e2e
2831 .iter()
2832 .filter(|o| !o.ok())
2833 .map(|o| {
2834 format!(
2835 "`{}` -> {:?}\n{}",
2836 o.command,
2837 o.code,
2838 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
2839 )
2840 })
2841 .collect();
2842 let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
2843
2844 if red.is_empty() {
2845 self.state.event(
2846 "review",
2847 format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
2848 );
2849 self.state.status = RunStatus::Gating;
2850 } else {
2851 self.state
2852 .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
2853 self.state.status = RunStatus::Blocked;
2854 }
2855 self.state.save()?;
2856 Ok(())
2857 }
2858
2859 async fn gate(&mut self) -> Result<()> {
2862 if self.state.status == RunStatus::Failed
2874 || self
2875 .state
2876 .base_sync
2877 .as_ref()
2878 .is_some_and(|s| s.conflict.is_some())
2879 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
2880 != Some(RunStatus::Gating)
2881 {
2882 return Ok(());
2883 }
2884 if !self.state.gate.is_empty() {
2885 if self.state.gate.iter().any(|outcome| !outcome.ok()) {
2893 self.state.status = RunStatus::Blocked;
2894 self.state.save()?;
2895 }
2896 return Ok(());
2897 }
2898 let Some(winner) = self.state.winner().cloned() else {
2899 return Ok(());
2900 };
2901 self.state.status = RunStatus::Gating;
2902 let shell = self.state.config.shell();
2903 let outcomes = run_commands(
2904 &shell,
2905 &self.state.config.verify.gate,
2906 &winner.worktree,
2907 Duration::from_secs(self.state.config.graph.verify_timeout()),
2908 )
2909 .await;
2910 for o in &outcomes {
2911 self.state.event(
2912 "gate",
2913 format!(
2914 "`{}` -> {}",
2915 o.command,
2916 if o.ok() {
2917 "pass".to_owned()
2918 } else {
2919 format!(
2920 "FAIL ({:?})\n{}",
2921 o.code,
2922 tail(&o.output_tail, EVENT_OUTPUT_TAIL)
2923 )
2924 }
2925 ),
2926 );
2927 }
2928 let passed = outcomes.iter().all(CommandOutcome::ok);
2929 self.state.gate = outcomes;
2930 if !passed {
2931 self.state.status = RunStatus::Blocked;
2932 self.state.event("gate", "gate failed; not merging");
2933 }
2934 self.state.save()?;
2935 Ok(())
2936 }
2937
2938 async fn merge(&mut self) -> Result<()> {
2941 if self
2956 .state
2957 .base_sync
2958 .as_ref()
2959 .is_some_and(|s| s.conflict.is_some())
2960 || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
2961 != Some(RunStatus::Gating)
2962 || self.state.gate.iter().any(|o| !o.ok())
2963 {
2964 return Ok(());
2965 }
2966 if self.state.merge.is_some() {
2975 return Ok(());
2976 }
2977 let Some(winner) = self.state.winner().cloned() else {
2978 return Ok(());
2979 };
2980 let repo = self.state.repo.clone();
2981 let base = self.state.base_branch.clone();
2982 let mode = self.state.config.merge.mode;
2983 let style = self.state.config.merge.style;
2984 let message = pr_body(&self.state, winner.label);
2985
2986 let outcome = match mode {
2987 MergeMode::None => MergeOutcome {
2988 mode,
2989 ok: true,
2990 detail: manual_merge_command(style, &repo, &winner.branch, &message),
2991 },
2992 MergeMode::Local => {
2993 let on = git::current_branch(&repo).await?;
2994 if on.as_deref() != Some(base.as_str()) {
2995 MergeOutcome {
2996 mode,
2997 ok: false,
2998 detail: format!(
2999 "{} has {} checked out, not the base branch {base}",
3000 repo.display(),
3001 on.unwrap_or_else(|| "a detached HEAD".to_owned())
3002 ),
3003 }
3004 } else if !git::is_clean(&repo).await? {
3005 MergeOutcome {
3006 mode,
3007 ok: false,
3008 detail: format!("{} is dirty; refusing to merge", repo.display()),
3009 }
3010 } else {
3011 let out = match style {
3012 MergeStyle::Merge => {
3013 git::merge_no_ff(&repo, &winner.branch, &message).await?
3014 }
3015 MergeStyle::Squash => {
3016 git::merge_squash(&repo, &winner.branch, &message).await?
3017 }
3018 MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
3019 };
3020 MergeOutcome {
3021 mode,
3022 ok: out.ok(),
3023 detail: if out.ok() { out.stdout } else { out.stderr },
3024 }
3025 }
3026 }
3027 MergeMode::Pr => {
3028 let remote = self.state.config.merge.remote.clone();
3029 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
3030 if !pushed.ok() {
3031 MergeOutcome {
3032 mode,
3033 ok: false,
3034 detail: pushed.stderr,
3035 }
3036 } else {
3037 let out = gh_pr_create(&winner.worktree, &base, &winner.branch, &message).await;
3038 match out {
3039 Ok(url) => MergeOutcome {
3040 mode,
3041 ok: true,
3042 detail: url,
3043 },
3044 Err(e) => MergeOutcome {
3045 mode,
3046 ok: false,
3047 detail: e.to_string(),
3048 },
3049 }
3050 }
3051 }
3052 };
3053
3054 self.state.status = match (mode, outcome.ok) {
3055 (MergeMode::None, _) => RunStatus::Ready,
3056 (_, true) => RunStatus::Merged,
3057 (_, false) => RunStatus::Blocked,
3058 };
3059 self.state.event(
3060 "merge",
3061 format!(
3062 "{:?}: {}",
3063 mode,
3064 outcome.detail.lines().next().unwrap_or("")
3065 ),
3066 );
3067 self.state.merge = Some(outcome);
3068 self.state.save()?;
3069
3070 if self.state.config.graph.land
3076 && mode == MergeMode::Pr
3077 && self.state.status == RunStatus::Merged
3078 {
3079 self.run_land().await?;
3080 }
3081 self.settle_questions();
3086 Ok(())
3087 }
3088
3089 async fn run_land(&mut self) -> Result<()> {
3100 let url = self
3101 .state
3102 .merge
3103 .as_ref()
3104 .map(|m| m.detail.clone())
3105 .unwrap_or_default();
3106 let url = url.lines().next().unwrap_or("").trim().to_owned();
3107 if !url.starts_with("http") {
3108 return Ok(());
3109 }
3110 match land::land(&mut self.state, &url).await {
3113 Ok(pr) if self.state.parked => {
3114 let _ = pr;
3118 }
3119 Ok(pr) => {
3120 self.state.status = match pr.state {
3121 land::PrLifecycle::Merged => RunStatus::Merged,
3122 _ => RunStatus::Blocked,
3123 };
3124 if bump::should_release_bump(self.state.status)
3131 && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
3132 {
3133 self.state
3134 .event("bump", format!("release bump skipped: {e:#}"));
3135 }
3136 self.state.save()?;
3137 }
3138 Err(e) => {
3139 self.state.status = RunStatus::Blocked;
3140 self.state.event("land", format!("gave up: {e}"));
3141 self.state.save()?;
3142 }
3143 }
3144 Ok(())
3145 }
3146
3147 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
3151 if let Some(existing) = self.state.seats.get(key)
3152 && existing.agent == agent
3153 {
3154 return existing.clone();
3155 }
3156 let fresh = SeatState::new(key, agent, self.state.seed);
3157 self.state.seats.insert(key.to_owned(), fresh.clone());
3158 fresh
3159 }
3160
3161 fn view(&self, c: &Candidate) -> CandidateView {
3163 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
3164 .unwrap_or_default();
3165 let (patch, _) = blind::sanitize_patch(
3166 &format!("candidate {} patch", c.label),
3167 &raw,
3168 &self.state.config.blind,
3169 );
3170 CandidateView {
3171 label: c.label,
3172 branch: c.branch.clone(),
3173 summary: c.summary.clone(),
3174 stat: c.stat.clone(),
3175 patch,
3176 }
3177 }
3178
3179 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
3181 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
3182 prompt::judge(
3183 "(see above)",
3184 &views,
3185 self.roles.judges.len(),
3186 base_short,
3187 "en",
3188 )
3189 }
3190
3191 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
3198 let mut turns = Vec::new();
3199 for j in &self.state.judgements {
3200 if j.ranking.is_empty() {
3201 continue;
3202 }
3203 let reasons = j
3204 .reasons
3205 .iter()
3206 .map(|(k, v)| format!("- {k}: {v}"))
3207 .collect::<Vec<_>>()
3208 .join("\n");
3209 turns.push(Turn {
3210 who: format!("Judge {} (opening ranking)", j.judge),
3211 is_self: j.judge == self_idx + 1,
3212 body: format!(
3213 "Ranked {}{}{reasons}",
3214 j.ranking.iter().collect::<String>(),
3215 if reasons.is_empty() {
3216 ""
3217 } else {
3218 ", because:\n"
3219 }
3220 ),
3221 });
3222 }
3223 for t in self
3224 .state
3225 .deliberation
3226 .iter()
3227 .flat_map(|r| r.turns.iter())
3228 .chain(current)
3229 {
3230 turns.push(Turn {
3231 who: format!("Judge {}", t.judge),
3232 is_self: t.judge == self_idx + 1,
3233 body: t.body.clone(),
3234 });
3235 }
3236 turns
3237 }
3238}
3239
3240fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
3242 agent::has_session(spec.kind, seat, sessions)
3243}
3244
3245fn short(commit: &str) -> String {
3246 commit.chars().take(7).collect()
3247}
3248
3249fn make_executable(path: &Path) -> Result<()> {
3250 #[cfg(unix)]
3251 {
3252 use std::os::unix::fs::PermissionsExt as _;
3253 let mut perms = std::fs::metadata(path)?.permissions();
3254 perms.set_mode(0o755);
3255 std::fs::set_permissions(path, perms)?;
3256 }
3257 #[cfg(not(unix))]
3258 {
3259 let _ = path;
3260 }
3261 Ok(())
3262}
3263
3264struct WaveCtx<'a> {
3271 run: &'a str,
3274 node: &'a str,
3276 prompts: &'a Prompts,
3277 cache: Option<&'a Path>,
3279}
3280
3281async fn run_one(
3283 job: SeatJob,
3284 sem: Arc<Semaphore>,
3285 ctx: &WaveCtx<'_>,
3286 state: &mut RunState,
3287 attempt: usize,
3288) -> (SeatState, AgentOutcome) {
3289 let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
3290 .await
3291 .pop()
3292 .expect("one job in, one result out");
3293 (seat, out)
3294}
3295
3296async fn wave(
3302 jobs: Vec<SeatJob>,
3303 sem: Arc<Semaphore>,
3304 ctx: &WaveCtx<'_>,
3305 state: &mut RunState,
3306 attempt: usize,
3307) -> Vec<(usize, SeatState, AgentOutcome)> {
3308 let WaveCtx {
3309 run,
3310 node,
3311 prompts,
3312 cache,
3313 } = *ctx;
3314 for job in &jobs {
3315 state.seat_started(node, &job.seat.key, job.timeout, attempt);
3316 }
3317 if let Err(e) = state.save() {
3318 tracing::warn!("could not persist in-progress seats: {e:#}");
3323 }
3324 let mut set = tokio::task::JoinSet::new();
3325 let overlay = prompts.overlay(node);
3326 for (i, mut job) in jobs.into_iter().enumerate() {
3327 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
3328 if cache.is_some() {
3329 job.prompt.push('\n');
3330 job.prompt.push_str(&prompt::build_cache_note(node));
3331 }
3332 let sem = Arc::clone(&sem);
3333 let run = run.to_owned();
3334 let node = node.to_owned();
3335 let cache = cache.map(Path::to_path_buf);
3336 set.spawn(async move {
3337 let _permit = sem.acquire().await;
3338 let mut seat = job.seat;
3339 let out = agent::invoke(
3340 &job.spec,
3341 &mut seat,
3342 &Invocation {
3343 cwd: &job.cwd,
3344 prompt: &job.prompt,
3345 timeout: job.timeout,
3346 allow_write: job.allow_write,
3347 sessions: job.sessions,
3348 artifacts: &job.artifacts,
3349 stem: &job.stem,
3350 run: &run,
3351 node: &node,
3352 cache_dir: cache.as_deref(),
3353 attachments: &[],
3354 },
3355 )
3356 .await;
3357 let out = match out {
3358 Ok(o) if o.usable() => AgentOutcome::Ok(o),
3359 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
3360 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
3368 Ok(o) if o.timed_out => AgentOutcome::Failed("timed out".to_owned()),
3369 Ok(o) => AgentOutcome::Failed(format!(
3370 "exited with {:?} and no usable output",
3371 o.exit_code
3372 )),
3373 Err(e) => AgentOutcome::Failed(e.to_string()),
3374 };
3375 (i, seat, out)
3376 });
3377 }
3378 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
3379 while let Some(joined) = set.join_next().await {
3380 let (i, seat, out) = match joined {
3381 Ok(v) => v,
3382 Err(e) => {
3386 tracing::error!("agent task panicked: {e}");
3387 continue;
3388 }
3389 };
3390 state.seat_finished(&seat.key);
3391 if let Err(e) = state.save() {
3392 tracing::warn!("could not persist a seat's completion: {e:#}");
3393 }
3394 if collected.len() <= i {
3395 collected.resize_with(i + 1, || None);
3396 }
3397 collected[i] = Some((i, seat, out));
3398 }
3399 if state
3405 .active
3406 .values()
3407 .any(|a| a.node == node && a.attempt == attempt)
3408 {
3409 state
3410 .active
3411 .retain(|_, a| !(a.node == node && a.attempt == attempt));
3412 if let Err(e) = state.save() {
3413 tracing::warn!("could not persist the end of a wave: {e:#}");
3414 }
3415 }
3416 collected.into_iter().flatten().collect()
3417}
3418
3419fn round_is_clean(
3440 blocking: usize,
3441 e2e_ok: bool,
3442 answered: usize,
3443 expected: usize,
3444 quota_missing: usize,
3445 policy: IncompleteReviewPolicy,
3446) -> bool {
3447 if blocking != 0 || !e2e_ok {
3448 return false;
3449 }
3450 if answered == expected || policy == IncompleteReviewPolicy::Warn {
3451 return true;
3452 }
3453 answered > 0 && expected - answered <= quota_missing
3454}
3455
3456fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
3473 if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
3474 return Some(RunStatus::Gating);
3475 }
3476 let last = reviews.last()?;
3477 let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
3478 if reviews.len() < max_rounds && !stagnant {
3479 return None;
3480 }
3481 Some(if last.incomplete() && last.blocking == 0 {
3482 RunStatus::Blocked
3483 } else if last.e2e.iter().all(CommandOutcome::ok) {
3484 RunStatus::Gating
3485 } else {
3486 RunStatus::Blocked
3487 })
3488}
3489
3490fn retry_budget(full: Duration, nudged: bool) -> Duration {
3505 if nudged {
3506 (full / 4).max(Duration::from_secs(120)).min(full)
3507 } else {
3508 full
3509 }
3510}
3511
3512#[allow(clippy::too_many_arguments)]
3525async fn ask_json_wave<T>(
3526 jobs: Vec<SeatJob>,
3527 sem: Arc<Semaphore>,
3528 retries: usize,
3529 ctx: &WaveCtx<'_>,
3530 losses: &mut Vec<QuotaLoss>,
3531 state: &mut RunState,
3532 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
3533) -> Vec<(SeatState, Result<(T, AgentOutput)>)>
3534where
3535 T: serde::de::DeserializeOwned + Send + 'static,
3536{
3537 let n = jobs.len();
3538 let originals: Vec<SeatJob> = jobs;
3539 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
3540 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
3541 let mut pending: Vec<usize> = (0..n).collect();
3542
3543 for attempt in 0..=retries {
3544 if pending.is_empty() {
3545 break;
3546 }
3547 let mut batch = Vec::with_capacity(pending.len());
3548 for &i in &pending {
3549 let src = &originals[i];
3550 let (prompt, timeout) = if attempt == 0 {
3553 (src.prompt.clone(), src.timeout)
3554 } else {
3555 let why = done[i]
3556 .as_ref()
3557 .and_then(|r| r.as_ref().err().map(ToString::to_string))
3558 .unwrap_or_else(|| "no parsable answer".to_owned());
3559 let nudge = prompt::nudge(&why);
3560 let nudged = has_context(&src.spec, &seats[i], src.sessions);
3561 let prompt = if nudged {
3562 nudge
3563 } else {
3564 format!("{}\n\n---\n\n{}", src.prompt, nudge)
3565 };
3566 (prompt, retry_budget(src.timeout, nudged))
3567 };
3568 batch.push(SeatJob {
3569 spec: src.spec.clone(),
3570 seat: seats[i].clone(),
3571 cwd: src.cwd.clone(),
3572 prompt,
3573 timeout,
3574 allow_write: src.allow_write,
3575 sessions: src.sessions,
3576 artifacts: src.artifacts.clone(),
3577 stem: if attempt == 0 {
3578 src.stem.clone()
3579 } else {
3580 format!("{}-retry{attempt}", src.stem)
3581 },
3582 });
3583 }
3584
3585 if attempt > 0 {
3586 let seats_out: Vec<&str> = pending
3587 .iter()
3588 .map(|&i| originals[i].seat.key.as_str())
3589 .collect();
3590 state.event(
3591 ctx.node,
3592 format!("retry {attempt}: re-asking {}", seats_out.join(", ")),
3593 );
3594 }
3595 let results = wave(batch, Arc::clone(&sem), ctx, state, attempt).await;
3596 let mut still = Vec::new();
3597 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
3598 seats[i] = seat;
3599 let (parsed, quota) = match out {
3600 AgentOutcome::Ok(o) => (
3601 match verdict::extract_json::<T>(&o.text) {
3602 Ok(v) => match validate(&v) {
3603 Ok(()) => Ok((v, o)),
3604 Err(e) => Err(e),
3605 },
3606 Err(e) => Err(e),
3607 },
3608 false,
3609 ),
3610 AgentOutcome::Quota(o) => {
3611 losses.push(QuotaLoss {
3612 seat: originals[i].seat.key.clone(),
3613 node: ctx.node.to_owned(),
3614 at: Timestamp::now(),
3615 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3616 });
3617 (
3618 Err(anyhow::anyhow!("rate limited (quota); not retrying now")),
3619 true,
3620 )
3621 }
3622 AgentOutcome::Dropped(o) => {
3627 let why = o
3628 .dropped
3629 .as_ref()
3630 .map(|d| d.why.as_str())
3631 .unwrap_or("the CLI ended the stream without delivering its answer");
3632 (
3633 Err(anyhow::anyhow!("the CLI dropped the stream ({why})")),
3634 false,
3635 )
3636 }
3637 AgentOutcome::Failed(e) => (Err(anyhow::anyhow!(e)), false),
3638 };
3639 let failed = parsed.is_err();
3640 done[i] = Some(parsed);
3641 if failed && !quota {
3644 still.push(i);
3645 }
3646 }
3647 pending = still;
3648 }
3649
3650 seats
3651 .into_iter()
3652 .zip(done)
3653 .map(|(seat, res)| {
3654 (
3655 seat,
3656 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
3657 )
3658 })
3659 .collect()
3660}
3661
3662fn e2e_outcome_label(o: &CommandOutcome) -> String {
3666 if o.ok() {
3667 return "pass".to_owned();
3668 }
3669 let reason = if o.build_failed() {
3670 format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
3671 } else {
3672 format!("FAIL ({:?})", o.code)
3673 };
3674 format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
3675}
3676
3677async fn run_e2e_with_retry(
3685 state: &mut RunState,
3686 shell: &[String],
3687 commands: &[String],
3688 worktree: &Path,
3689 timeout: Duration,
3690 context: &str,
3691) -> (Vec<CommandOutcome>, bool) {
3692 let mut e2e = run_commands(shell, commands, worktree, timeout).await;
3693 for o in &e2e {
3694 state.event(
3695 "verify",
3696 format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
3697 );
3698 }
3699 let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
3703 if verify_retried {
3704 state.event(
3705 "verify",
3706 format!(
3707 "{context}: verify could not build/link, not a test result — retrying once \
3708 before concluding"
3709 ),
3710 );
3711 e2e = run_commands(shell, commands, worktree, timeout).await;
3712 for o in &e2e {
3713 state.event(
3714 "verify",
3715 format!(
3716 "{context}: retry `{}` -> {}",
3717 o.command,
3718 e2e_outcome_label(o)
3719 ),
3720 );
3721 }
3722 }
3723 (e2e, verify_retried)
3724}
3725
3726async fn run_commands(
3728 shell: &[String],
3729 commands: &[String],
3730 cwd: &Path,
3731 timeout: Duration,
3732) -> Vec<CommandOutcome> {
3733 let mut out = Vec::new();
3734 for command in commands {
3735 let started = Instant::now();
3736 let mut cmd = tokio::process::Command::new(&shell[0]);
3737 cmd.quiet();
3738 cmd.args(&shell[1..])
3739 .arg(command)
3740 .current_dir(cwd)
3741 .stdin(std::process::Stdio::null())
3742 .stdout(std::process::Stdio::piped())
3743 .stderr(std::process::Stdio::piped())
3744 .kill_on_drop(true);
3745 let spawned = cmd.spawn();
3746 let (code, body) = match spawned {
3747 Ok(child) => match tokio::time::timeout(timeout, child.wait_with_output()).await {
3748 Ok(Ok(o)) => {
3749 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
3750 body.push_str(&String::from_utf8_lossy(&o.stderr));
3751 (o.status.code(), body)
3752 }
3753 Ok(Err(e)) => (None, format!("failed to run: {e}")),
3754 Err(_) => (None, format!("timed out after {}s", timeout.as_secs())),
3755 },
3756 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
3757 };
3758 out.push(CommandOutcome {
3759 command: command.clone(),
3760 code,
3761 output_tail: tail(&body, OUTPUT_TAIL),
3762 duration_ms: started.elapsed().as_millis() as u64,
3763 });
3764 }
3765 out
3766}
3767
3768fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
3780 let repo = repo.display();
3781 match style {
3782 MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
3783 MergeStyle::Squash => {
3784 let subject = message.lines().next().unwrap_or(branch);
3785 format!(
3786 "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
3787 )
3788 }
3789 MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
3790 }
3791}
3792
3793fn pr_body(state: &RunState, winner: char) -> String {
3819 let instruction = state.instruction.trim_start();
3820 let mut message = if instruction.is_empty() {
3821 "(empty task)".to_owned()
3822 } else {
3823 instruction.to_owned()
3824 };
3825
3826 let open = state.open_findings();
3827 if !open.is_empty() {
3828 message.push_str("\n\n## Open review findings\n\n");
3829 for f in &open {
3830 message.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
3831 }
3832 }
3833
3834 if let Some(fix) = state.reviews.last().and_then(|r| r.fix.as_ref())
3835 && !fix.rejected.is_empty()
3836 {
3837 message.push_str("\n## Declined by the fixer\n\n");
3838 for r in &fix.rejected {
3839 message.push_str(&format!("- `{}`: {}\n", r.id, r.why));
3840 }
3841 }
3842
3843 message.push_str(&format!(
3844 "\n\n---\nmagi:run/{} magi:candidate-{}\n",
3845 state.id,
3846 winner.to_ascii_lowercase()
3847 ));
3848
3849 message
3850}
3851
3852async fn gh_pr_create(cwd: &Path, base: &str, head: &str, body: &str) -> Result<String> {
3854 let title = body.lines().next().unwrap_or("magi run").to_owned();
3855 let out = tokio::process::Command::new("gh")
3856 .args([
3857 "pr", "create", "--base", base, "--head", head, "--title", &title, "--body", body,
3858 ])
3859 .current_dir(cwd)
3860 .quiet()
3861 .stdin(std::process::Stdio::null())
3862 .output()
3863 .await
3864 .context("spawn gh")?;
3865 if out.status.success() {
3866 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
3867 } else {
3868 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
3869 }
3870}
3871
3872pub async fn fold_run(state: &mut RunState, drop_winner: bool) -> Result<Vec<String>> {
3874 let repo = state.repo.clone();
3875 let root = state.worktree_root();
3876 let winner = state.tally.as_ref().map(|t| t.winner);
3877 let mut removed = Vec::new();
3878
3879 for i in 0..state.candidates.len() {
3880 let c = state.candidates[i].clone();
3881 let is_winner = Some(c.label) == winner;
3882 if is_winner && !drop_winner {
3883 continue;
3884 }
3885 if c.worktree.exists() {
3886 git::worktree_remove(&repo, &c.worktree).await.ok();
3887 removed.push(c.worktree.to_string_lossy().into_owned());
3888 }
3889 if git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
3890 git::branch_delete(&repo, &c.branch).await.ok();
3891 removed.push(c.branch.clone());
3892 }
3893 state.candidates[i].folded = true;
3894 }
3895
3896 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
3897 let path = name.path();
3898 let keep = !drop_winner
3899 && winner.is_some_and(|w| {
3900 path.file_name()
3901 .is_some_and(|n| n == format!("cand-{w}").as_str())
3902 });
3903 if keep {
3904 continue;
3905 }
3906 git::worktree_remove(&repo, &path).await.ok();
3907 removed.push(path.to_string_lossy().into_owned());
3908 }
3909
3910 remove_if_empty(&root);
3919
3920 if state.enabled_worktree_config && drop_winner {
3921 git::release_worktree_config(&repo).await.ok();
3925 state.enabled_worktree_config = false;
3926 }
3927 state.save()?;
3928 Ok(removed)
3929}
3930
3931fn remove_if_empty(dir: &Path) {
3942 if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
3943 std::fs::remove_dir(dir).ok();
3944 }
3945}
3946
3947pub fn worst_open(state: &RunState) -> Option<Severity> {
3949 state
3950 .reviews
3951 .last()?
3952 .reviews
3953 .iter()
3954 .flat_map(|r| r.findings.iter())
3955 .map(|f| f.severity)
3956 .max()
3957}
3958
3959#[cfg(test)]
3960mod tests {
3961 use super::*;
3962 use std::collections::BTreeMap;
3963 use std::time::Duration;
3964
3965 fn conductor() -> AgentSpec {
3966 AgentSpec {
3967 id: "conductor".to_owned(),
3968 kind: crate::config::AgentKind::Command,
3969 model: None,
3970 command: vec!["true".to_owned()],
3971 extra_args: Vec::new(),
3972 env: BTreeMap::new(),
3973 prompt_delivery: None,
3974 }
3975 }
3976
3977 #[test]
3978 fn remove_if_empty_only_ever_takes_a_bare_directory() {
3979 let dir = tempfile::tempdir().unwrap();
3980 let bay = dir.path().join("ffff");
3981
3982 remove_if_empty(&bay);
3984 assert!(!bay.exists());
3985
3986 std::fs::create_dir_all(bay.join("cand-A")).unwrap();
3989 remove_if_empty(&bay);
3990 assert!(bay.exists(), "non-empty directory must survive");
3991
3992 std::fs::remove_dir(bay.join("cand-A")).unwrap();
3994 remove_if_empty(&bay);
3995 assert!(!bay.exists(), "an empty bay is a leftover, not a record");
3996 }
3997
3998 #[test]
4007 fn a_full_panel_that_found_nothing_is_clean() {
4008 assert!(round_is_clean(
4009 0,
4010 true,
4011 2,
4012 2,
4013 0,
4014 IncompleteReviewPolicy::Block
4015 ));
4016 }
4017
4018 #[test]
4019 fn a_missing_seat_is_never_clean_under_the_default_policy() {
4020 assert!(!round_is_clean(
4021 0,
4022 true,
4023 1,
4024 2,
4025 0,
4026 IncompleteReviewPolicy::Block
4027 ));
4028 }
4029
4030 #[test]
4031 fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
4032 assert!(!round_is_clean(
4033 1,
4034 true,
4035 1,
4036 2,
4037 0,
4038 IncompleteReviewPolicy::Warn
4039 ));
4040 }
4041
4042 #[test]
4043 fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
4044 assert!(round_is_clean(
4045 0,
4046 true,
4047 1,
4048 2,
4049 0,
4050 IncompleteReviewPolicy::Warn
4051 ));
4052 }
4053
4054 #[test]
4055 fn a_full_panel_with_an_open_finding_is_not_clean() {
4056 assert!(!round_is_clean(
4057 1,
4058 true,
4059 2,
4060 2,
4061 0,
4062 IncompleteReviewPolicy::Block
4063 ));
4064 }
4065
4066 #[test]
4067 fn a_full_panel_with_a_red_e2e_is_not_clean() {
4068 assert!(!round_is_clean(
4069 0,
4070 false,
4071 2,
4072 2,
4073 0,
4074 IncompleteReviewPolicy::Block
4075 ));
4076 }
4077
4078 #[test]
4085 fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
4086 assert!(round_is_clean(
4089 0,
4090 true,
4091 1,
4092 2,
4093 1,
4094 IncompleteReviewPolicy::Block
4095 ));
4096 }
4097
4098 #[test]
4099 fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
4100 assert!(!round_is_clean(
4103 0,
4104 true,
4105 1,
4106 2,
4107 0,
4108 IncompleteReviewPolicy::Block
4109 ));
4110 }
4111
4112 #[test]
4113 fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
4114 assert!(!round_is_clean(
4115 1,
4116 true,
4117 1,
4118 2,
4119 1,
4120 IncompleteReviewPolicy::Block
4121 ));
4122 assert!(!round_is_clean(
4123 0,
4124 false,
4125 1,
4126 2,
4127 1,
4128 IncompleteReviewPolicy::Block
4129 ));
4130 }
4131
4132 #[test]
4133 fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
4134 assert!(!round_is_clean(
4138 0,
4139 true,
4140 0,
4141 2,
4142 2,
4143 IncompleteReviewPolicy::Block
4144 ));
4145 }
4146
4147 fn review_round(
4153 clean: bool,
4154 blocking: usize,
4155 answered: usize,
4156 expected: usize,
4157 progressed: bool,
4158 e2e_ok: bool,
4159 ) -> ReviewRound {
4160 ReviewRound {
4161 round: 1,
4162 head: "h".to_owned(),
4163 verified_head: None,
4164 reviews: Vec::new(),
4165 e2e: vec![CommandOutcome {
4166 command: "test".to_owned(),
4167 code: Some(if e2e_ok { 0 } else { 1 }),
4168 output_tail: String::new(),
4169 duration_ms: 0,
4170 }],
4171 verify_retried: false,
4172 e2e_deferred: false,
4173 e2e_defer_reason: None,
4174 fix: None,
4175 blocking,
4176 answered,
4177 expected,
4178 clean,
4179 progressed,
4180 vote_split: false,
4181 reconsideration: Vec::new(),
4182 verdict: None,
4183 }
4184 }
4185
4186 #[test]
4187 fn review_conclusion_is_none_when_nothing_has_run() {
4188 assert_eq!(review_conclusion(&[], 3), None);
4189 }
4190
4191 #[test]
4192 fn review_conclusion_is_none_while_rounds_remain() {
4193 let rounds = vec![review_round(false, 1, 2, 2, true, true)];
4194 assert_eq!(review_conclusion(&rounds, 3), None);
4195 }
4196
4197 #[test]
4198 fn review_conclusion_is_gating_once_a_round_is_clean() {
4199 let rounds = vec![review_round(true, 0, 2, 2, false, true)];
4200 assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
4201 }
4202
4203 #[test]
4204 fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
4205 let rounds = vec![
4206 review_round(false, 1, 2, 2, true, true),
4207 review_round(false, 1, 2, 2, true, true),
4208 ];
4209 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
4210 }
4211
4212 #[test]
4213 fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
4214 let rounds = vec![
4215 review_round(false, 1, 2, 2, true, true),
4216 review_round(false, 1, 2, 2, true, false),
4217 ];
4218 assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
4219 }
4220
4221 #[test]
4222 fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
4223 let rounds = vec![review_round(false, 0, 1, 2, false, true)];
4225 assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
4226 }
4227
4228 #[test]
4229 fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
4230 let rounds = vec![
4231 review_round(false, 1, 2, 2, false, true),
4232 review_round(false, 1, 2, 2, false, true),
4233 ];
4234 assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
4235 }
4236
4237 fn secs(n: u64) -> Duration {
4238 Duration::from_secs(n)
4239 }
4240
4241 fn init_repo(dir: &Path) {
4244 let run = |args: &[&str]| {
4245 let out = std::process::Command::new("git")
4246 .args(args)
4247 .current_dir(dir)
4248 .quiet()
4249 .output()
4250 .expect("spawn git");
4251 assert!(
4252 out.status.success(),
4253 "git {args:?} failed: {}",
4254 String::from_utf8_lossy(&out.stderr)
4255 );
4256 };
4257 run(&["init", "-b", "main"]);
4258 run(&["config", "user.name", "magi test"]);
4259 run(&["config", "user.email", "magi@example.com"]);
4260 std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
4261 run(&["add", "-A"]);
4262 run(&["commit", "-m", "init"]);
4263 }
4264
4265 fn ask_test_home() {
4273 crate::run::set_home(std::env::temp_dir().join("magi-graph-ask-tests-home"));
4274 }
4275
4276 fn runner_at(status: RunStatus) -> Runner {
4279 let mut state = RunState::new(
4280 PathBuf::from("/nonexistent/repo"),
4281 "main".to_owned(),
4282 "deadbeef".to_owned(),
4283 "task".to_owned(),
4284 Config::default(),
4285 );
4286 state.status = status;
4287 Runner {
4288 state,
4289 roles: ResolvedRoles {
4290 implementers: Vec::new(),
4291 judges: Vec::new(),
4292 reviewers: Vec::new(),
4293 fixer: None,
4294 conductor: conductor(),
4295 },
4296 sem: Arc::new(Semaphore::new(1)),
4297 pause: Pause::new(),
4298 }
4299 }
4300
4301 fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
4303 let mut q = ask::Question::new(
4304 run.to_owned(),
4305 "implement".to_owned(),
4306 "impl-A".to_owned(),
4307 "Which storage backend should the cache use?".to_owned(),
4308 String::new(),
4309 vec!["SQLite".to_owned(), "Redis".to_owned()],
4310 );
4311 store.put(&mut q).unwrap();
4312 q
4313 }
4314
4315 #[test]
4316 fn a_failed_runs_open_question_is_abandoned() {
4317 ask_test_home();
4318 let store = ask::Questions::open();
4319 let mut runner = runner_at(RunStatus::Failed);
4320 let run = runner.state.id.clone();
4321 let q = ask_open_question(&store, &run);
4322
4323 runner.settle_questions();
4324
4325 let back = store.get(&q.id).unwrap();
4326 assert!(
4327 !back.status.open(),
4328 "the seat that asked died with the run; nobody is left to read an answer"
4329 );
4330 assert!(
4331 back.detail.contains(&run) && back.detail.contains("failed"),
4332 "the reason names what the run became, not just that it is gone: {}",
4333 back.detail
4334 );
4335 }
4336
4337 #[test]
4338 fn a_merged_runs_open_question_is_abandoned_too() {
4339 ask_test_home();
4340 let store = ask::Questions::open();
4341 for status in [RunStatus::Merged, RunStatus::Ready] {
4344 let mut runner = runner_at(status);
4345 let run = runner.state.id.clone();
4346 let q = ask_open_question(&store, &run);
4347
4348 runner.settle_questions();
4349
4350 let back = store.get(&q.id).unwrap();
4351 assert!(
4352 !back.status.open(),
4353 "{status:?} run's question must not outlive the run"
4354 );
4355 }
4356 }
4357
4358 #[test]
4359 fn a_still_resumable_runs_open_question_is_left_alone() {
4360 ask_test_home();
4361 let store = ask::Questions::open();
4362 for status in [RunStatus::Blocked, RunStatus::Stalled] {
4368 let mut runner = runner_at(status);
4369 let run = runner.state.id.clone();
4370 let q = ask_open_question(&store, &run);
4371
4372 runner.settle_questions();
4373
4374 let back = store.get(&q.id).unwrap();
4375 assert!(
4376 back.status.open(),
4377 "{status:?} is still alive; the question must still be waiting"
4378 );
4379 }
4380 }
4381
4382 #[test]
4383 fn settle_questions_never_touches_an_already_answered_question() {
4384 ask_test_home();
4385 let store = ask::Questions::open();
4386 let mut runner = runner_at(RunStatus::Failed);
4387 let run = runner.state.id.clone();
4388 let mut q = ask_open_question(&store, &run);
4389 q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
4390 .unwrap();
4391 store.put(&mut q).unwrap();
4392
4393 runner.settle_questions();
4398 runner.settle_questions();
4399
4400 let back = store.get(&q.id).unwrap();
4401 assert_eq!(
4402 back.status,
4403 ask::QuestionStatus::Answered,
4404 "a real answer is a decision on record, never overwritten by a sweep"
4405 );
4406 }
4407
4408 #[tokio::test]
4417 async fn merge_does_not_reattempt_once_a_run_has_concluded() {
4418 let tmp = tempfile::tempdir().expect("tempdir");
4419 let repo = tmp.path().join("repo");
4420 std::fs::create_dir_all(&repo).unwrap();
4421 init_repo(&repo);
4422
4423 let mut config = Config::default();
4424 config.merge.mode = MergeMode::Local;
4425
4426 let mut state = RunState::new(
4427 repo.clone(),
4428 "main".to_owned(),
4429 "deadbeef".to_owned(),
4430 "task".to_owned(),
4431 config,
4432 );
4433 state.candidates = vec![Candidate {
4434 index: 0,
4435 label: 'A',
4436 agent: "alpha".to_owned(),
4437 branch: "does-not-exist".to_owned(),
4438 worktree: repo.clone(),
4439 summary: String::new(),
4440 stat: String::new(),
4441 files: 0,
4442 commits: 0,
4443 empty: false,
4444 failed: None,
4445 duration_ms: 0,
4446 folded: false,
4447 }];
4448 state.tally = Some(Tally {
4449 first_choice: BTreeMap::from([('A', 1)]),
4450 borda: BTreeMap::new(),
4451 winner: 'A',
4452 rankings: 1,
4453 unanimous_initial: true,
4454 deliberated: false,
4455 changed_votes: 0,
4456 unanimous_final: true,
4457 tie_break: None,
4458 judges: 0,
4459 present: 0,
4460 quorum: 0,
4461 met_quorum: true,
4462 uncontested: Some("only candidate A produced a change".to_owned()),
4463 });
4464 state.reviews = vec![ReviewRound {
4465 round: 1,
4466 head: "deadbeef".to_owned(),
4467 verified_head: None,
4468 reviews: Vec::new(),
4469 e2e: Vec::new(),
4470 fix: None,
4471 blocking: 0,
4472 answered: 0,
4473 expected: 0,
4474 clean: true,
4475 verify_retried: false,
4476 e2e_deferred: false,
4477 e2e_defer_reason: None,
4478 progressed: false,
4479 vote_split: false,
4480 reconsideration: Vec::new(),
4481 verdict: None,
4482 }];
4483 state.gate = vec![CommandOutcome {
4484 command: "test".to_owned(),
4485 code: Some(0),
4486 output_tail: String::new(),
4487 duration_ms: 0,
4488 }];
4489 state.status = RunStatus::Ready;
4494 state.merge = Some(MergeOutcome {
4495 mode: MergeMode::Local,
4496 ok: false,
4497 detail: "already concluded".to_owned(),
4498 });
4499
4500 let mut runner = Runner {
4501 state,
4502 roles: ResolvedRoles {
4503 implementers: Vec::new(),
4504 judges: Vec::new(),
4505 reviewers: Vec::new(),
4506 fixer: None,
4507 conductor: conductor(),
4508 },
4509 sem: Arc::new(Semaphore::new(1)),
4510 pause: Pause::new(),
4511 };
4512
4513 runner.merge().await.expect("merge");
4514
4515 assert_eq!(
4516 runner.state.status,
4517 RunStatus::Ready,
4518 "a concluded run's status must not change on reentry"
4519 );
4520 assert_eq!(
4521 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
4522 Some("already concluded"),
4523 "merge must not run again once the node already recorded an outcome"
4524 );
4525 }
4526
4527 #[tokio::test]
4528 async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
4529 crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
4530 let tmp = tempfile::tempdir().expect("tempdir");
4531 let repo = tmp.path().join("repo");
4532 std::fs::create_dir_all(&repo).unwrap();
4533 init_repo(&repo);
4534
4535 let mut config = Config::default();
4536 config.merge.mode = MergeMode::Pr;
4537 config.graph.land = true;
4538 config.graph.land_approval = false;
4539
4540 let mut state = RunState::new(
4541 repo.clone(),
4542 "main".to_owned(),
4543 "deadbeef".to_owned(),
4544 "task".to_owned(),
4545 config,
4546 );
4547 state.candidates = vec![Candidate {
4548 index: 0,
4549 label: 'A',
4550 agent: "alpha".to_owned(),
4551 branch: "does-not-exist".to_owned(),
4552 worktree: repo.clone(),
4553 summary: String::new(),
4554 stat: String::new(),
4555 files: 0,
4556 commits: 0,
4557 empty: false,
4558 failed: None,
4559 duration_ms: 0,
4560 folded: false,
4561 }];
4562 state.tally = Some(Tally {
4563 first_choice: BTreeMap::from([('A', 1)]),
4564 borda: BTreeMap::new(),
4565 winner: 'A',
4566 rankings: 1,
4567 unanimous_initial: true,
4568 deliberated: false,
4569 changed_votes: 0,
4570 unanimous_final: true,
4571 tie_break: None,
4572 judges: 0,
4573 present: 0,
4574 quorum: 0,
4575 met_quorum: true,
4576 uncontested: Some("only candidate A produced a change".to_owned()),
4577 });
4578 state.reviews = vec![ReviewRound {
4579 round: 1,
4580 head: "deadbeef".to_owned(),
4581 verified_head: None,
4582 reviews: Vec::new(),
4583 e2e: Vec::new(),
4584 fix: None,
4585 blocking: 0,
4586 answered: 0,
4587 expected: 0,
4588 clean: true,
4589 verify_retried: false,
4590 e2e_deferred: false,
4591 e2e_defer_reason: None,
4592 progressed: false,
4593 vote_split: false,
4594 reconsideration: Vec::new(),
4595 verdict: None,
4596 }];
4597 state.gate = vec![CommandOutcome {
4598 command: "test".to_owned(),
4599 code: Some(0),
4600 output_tail: String::new(),
4601 duration_ms: 0,
4602 }];
4603 state.status = RunStatus::Landing;
4607 state.merge = Some(MergeOutcome {
4608 mode: MergeMode::Pr,
4609 ok: true,
4610 detail: "https://example.invalid/x/y/pull/1".to_owned(),
4611 });
4612
4613 ask_test_home();
4617 let store = ask::Questions::open();
4618 let q = ask_open_question(&store, &state.id);
4619
4620 let mut runner = Runner {
4621 state,
4622 roles: ResolvedRoles {
4623 implementers: Vec::new(),
4624 judges: Vec::new(),
4625 reviewers: Vec::new(),
4626 fixer: None,
4627 conductor: conductor(),
4628 },
4629 sem: Arc::new(Semaphore::new(1)),
4630 pause: Pause::new(),
4631 };
4632
4633 runner.execute().await.expect("execute");
4638
4639 assert_eq!(
4640 runner.state.merge.as_ref().map(|m| m.detail.as_str()),
4641 Some("https://example.invalid/x/y/pull/1"),
4642 "reentry must not push again or open a second pull request over the \
4643 one `land` is already watching"
4644 );
4645 assert_ne!(
4646 runner.state.status,
4647 RunStatus::Landing,
4648 "land could not actually reach the fake pull request, so it must \
4649 have given up rather than left the run silently parked forever"
4650 );
4651 assert_eq!(runner.state.status, RunStatus::Blocked);
4655 assert!(
4656 store.get(&q.id).unwrap().status.open(),
4657 "Blocked is still alive; settle_questions must have been a no-op here"
4658 );
4659 }
4660
4661 fn state_with_round(round: ReviewRound) -> RunState {
4662 let mut s = RunState::new(
4663 PathBuf::from("/repo"),
4664 "main".to_owned(),
4665 "abc1234".to_owned(),
4666 "add retries".to_owned(),
4667 Config::default(),
4668 );
4669 s.reviews = vec![round];
4670 s
4671 }
4672
4673 fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
4674 crate::verdict::Finding {
4675 id: id.to_owned(),
4676 severity,
4677 file: None,
4678 line: None,
4679 title: title.to_owned(),
4680 detail: String::new(),
4681 }
4682 }
4683
4684 #[test]
4685 fn pr_body_names_open_findings_and_declined_ones() {
4686 let round = ReviewRound {
4687 round: 2,
4688 head: "deadbee".to_owned(),
4689 verified_head: None,
4690 reviews: vec![ReviewRecord {
4691 reviewer: 1,
4692 agent: "alpha".to_owned(),
4693 summary: String::new(),
4694 findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
4695 vote: None,
4696 failed: None,
4697 duration_ms: 0,
4698 }],
4699 e2e: vec![CommandOutcome {
4700 command: "cargo test".to_owned(),
4701 code: Some(0),
4702 output_tail: String::new(),
4703 duration_ms: 0,
4704 }],
4705 verify_retried: false,
4706 e2e_deferred: false,
4707 e2e_defer_reason: None,
4708 fix: Some(FixRecord {
4709 agent: "alpha".to_owned(),
4710 addressed: Vec::new(),
4711 rejected: vec![crate::verdict::Rejection {
4712 id: "R1-1-1".to_owned(),
4713 why: "not reachable from any caller".to_owned(),
4714 }],
4715 notes: String::new(),
4716 committed: true,
4717 failed: None,
4718 duration_ms: 0,
4719 }),
4720 blocking: 0,
4721 answered: 1,
4722 expected: 1,
4723 clean: false,
4724 progressed: true,
4725 vote_split: false,
4726 reconsideration: Vec::new(),
4727 verdict: None,
4728 };
4729 let state = state_with_round(round);
4730 let body = pr_body(&state, 'A');
4731
4732 assert!(body.contains("add retries"), "the task must still be there");
4733 assert!(body.contains("R2-1-1"), "{body}");
4734 assert!(body.contains("unused import"), "{body}");
4735 assert!(body.contains("R1-1-1"), "the declined finding: {body}");
4736 assert!(
4737 body.contains("not reachable from any caller"),
4738 "the reason it was declined: {body}"
4739 );
4740 }
4741
4742 #[test]
4743 fn pr_body_says_nothing_extra_when_the_round_was_clean() {
4744 let round = ReviewRound {
4745 round: 1,
4746 head: "deadbee".to_owned(),
4747 verified_head: None,
4748 reviews: vec![ReviewRecord {
4749 reviewer: 1,
4750 agent: "alpha".to_owned(),
4751 summary: String::new(),
4752 findings: Vec::new(),
4753 vote: None,
4754 failed: None,
4755 duration_ms: 0,
4756 }],
4757 e2e: Vec::new(),
4758 verify_retried: false,
4759 e2e_deferred: false,
4760 e2e_defer_reason: None,
4761 fix: None,
4762 blocking: 0,
4763 answered: 1,
4764 expected: 1,
4765 clean: true,
4766 progressed: false,
4767 vote_split: false,
4768 reconsideration: Vec::new(),
4769 verdict: None,
4770 };
4771 let state = state_with_round(round);
4772 let body = pr_body(&state, 'A');
4773 assert!(!body.contains("Open review findings"), "{body}");
4774 assert!(!body.contains("Declined"), "{body}");
4775 }
4776
4777 #[test]
4778 fn pr_body_titles_itself_from_the_task_not_run_or_candidate() {
4779 let state = RunState::new(
4780 PathBuf::from("/repo"),
4781 "main".to_owned(),
4782 "abc1234".to_owned(),
4783 "add retries".to_owned(),
4784 Config::default(),
4785 );
4786 let body = pr_body(&state, 'A');
4787 let title = body.lines().next().unwrap();
4788
4789 assert_eq!(
4790 title, "add retries",
4791 "the title must be the task, not run/candidate bookkeeping: {body}"
4792 );
4793 assert!(
4794 body.contains(&format!("magi:run/{}", state.id)),
4795 "the run id must still be recoverable from the footer: {body}"
4796 );
4797 assert!(
4798 body.contains("magi:candidate-a"),
4799 "the candidate must still be recoverable from the footer: {body}"
4800 );
4801 }
4802
4803 #[test]
4804 fn pr_body_never_titles_itself_off_a_blank_first_line() {
4805 let leading_blank = RunState::new(
4806 PathBuf::from("/repo"),
4807 "main".to_owned(),
4808 "abc1234".to_owned(),
4809 "\n\n \nadd retries\n\ndetails".to_owned(),
4810 Config::default(),
4811 );
4812 let body = pr_body(&leading_blank, 'A');
4813 assert_eq!(
4814 body.lines().next(),
4815 Some("add retries"),
4816 "a leading blank line must not become an empty title: {body}"
4817 );
4818
4819 let whitespace_only = RunState::new(
4820 PathBuf::from("/repo"),
4821 "main".to_owned(),
4822 "abc1234".to_owned(),
4823 " \n \n".to_owned(),
4824 Config::default(),
4825 );
4826 let body = pr_body(&whitespace_only, 'A');
4827 let title = body.lines().next().unwrap_or_default();
4828 assert!(
4829 !title.is_empty(),
4830 "a whitespace-only instruction must still fall back to a non-empty title: {body}"
4831 );
4832 }
4833
4834 #[test]
4835 fn manual_merge_command_matches_the_configured_style() {
4836 let repo = Path::new("/repo");
4837 let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
4838
4839 let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
4840 assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
4841
4842 let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
4843 assert_eq!(
4844 squash,
4845 "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
4846 \"Merge magi run 0832 (candidate A)\""
4847 );
4848
4849 let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
4850 assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
4851 }
4852
4853 #[test]
4854 fn a_nudge_gets_a_quarter_of_the_budget() {
4855 assert_eq!(retry_budget(secs(1200), true), secs(300));
4857 assert_eq!(retry_budget(secs(3600), true), secs(900));
4858 }
4859
4860 #[test]
4861 fn a_resent_prompt_keeps_the_whole_budget() {
4862 assert_eq!(retry_budget(secs(1200), false), secs(1200));
4865 assert_eq!(retry_budget(secs(60), false), secs(60));
4866 }
4867
4868 #[test]
4869 fn the_floor_never_exceeds_the_original_budget() {
4870 assert_eq!(retry_budget(secs(60), true), secs(60));
4874 assert_eq!(retry_budget(secs(480), true), secs(120));
4875 assert_eq!(retry_budget(secs(0), true), secs(0));
4876 }
4877}