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::blind;
31use crate::config::{AgentSpec, Config, LeakPolicy, MergeMode, Prompts, ResolvedRoles};
32use crate::git;
33use crate::land;
34use crate::prompt::{self, CandidateView, Turn};
35use crate::run::{
36 Candidate, CommandOutcome, DeliberationRound, DeliberationTurn, FixRecord, Judgement,
37 MergeOutcome, QuotaLoss, ReviewRecord, ReviewRound, RunState, RunStatus, Tally, VoteRecord,
38 tail, write_artifact,
39};
40use crate::verdict::{self, FinalVote, FixReport, Position, Ranking, Review, Severity};
41
42const OUTPUT_TAIL: usize = 8_000;
44
45struct SeatJob {
47 spec: AgentSpec,
48 seat: SeatState,
49 cwd: PathBuf,
50 prompt: String,
51 timeout: Duration,
52 allow_write: bool,
53 sessions: bool,
54 artifacts: PathBuf,
55 stem: String,
56}
57
58enum AgentOutcome {
64 Ok(AgentOutput),
66 Quota(AgentOutput),
68 Failed(String),
70}
71
72#[derive(Debug, Clone, Default)]
89pub struct Pause(Arc<AtomicBool>);
90
91impl Pause {
92 #[must_use]
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 pub fn park(&self) {
100 self.0.store(true, Ordering::SeqCst);
101 }
102
103 #[must_use]
105 pub fn parked(&self) -> bool {
106 self.0.load(Ordering::SeqCst)
107 }
108}
109
110pub struct Runner {
112 pub state: RunState,
114 roles: ResolvedRoles,
115 sem: Arc<Semaphore>,
116 pause: Pause,
118}
119
120async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
140 let tracking = format!("{remote}/{base_branch}");
141 let fetched = git::fetch(repo, remote, base_branch).await;
142 if let Ok(out) = &fetched
143 && out.ok()
144 && git::rev_exists(repo, &tracking).await
145 {
146 return git::rev_parse(repo, &tracking).await;
147 }
148 let why = match &fetched {
149 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
150 Ok(_) => format!("{remote} has no {base_branch}"),
151 Err(e) => e.to_string(),
152 };
153 tracing::warn!(
154 "could not read {tracking} ({why}); branching off the local \
155 {base_branch} instead, which may be behind"
156 );
157 git::rev_parse(repo, base_branch).await.with_context(|| {
158 format!(
159 "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
160 branch that exists"
161 )
162 })
163}
164
165impl Runner {
166 pub async fn start(repo: &Path, instruction: String, config: Config) -> Result<Self> {
168 let repo = git::toplevel(repo).await?;
169 let missing = agent::missing_programs(&config.agents);
170 if !missing.is_empty() {
171 bail!(
172 "these agent programs are not on PATH: {}. Fix the roster in \
173 magi.toml or install them.",
174 missing.join(", ")
175 );
176 }
177 let base_branch = match config.merge.base.clone() {
178 Some(b) => b,
179 None => git::current_branch(&repo)
180 .await?
181 .context("HEAD is detached; set [merge] base in magi.toml")?,
182 };
183 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
184 if !git::is_clean(&repo).await? {
188 tracing::warn!(
189 "{} has uncommitted changes; they are not part of this run, \
190 which branches off {base_branch} ({})",
191 repo.display(),
192 &base_commit[..base_commit.len().min(8)]
193 );
194 }
195 let roles = config.resolve_roles()?;
196 let max_parallel = config.graph.max_parallel.max(1);
197 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
198 state.event("start", format!("run {} created", state.id));
199 state.save()?;
200 Ok(Self {
201 state,
202 roles,
203 sem: Arc::new(Semaphore::new(max_parallel)),
204 pause: Pause::new(),
205 })
206 }
207
208 pub async fn review(repo: &Path, branch: &str, config: Config) -> Result<Self> {
222 let repo = git::toplevel(repo).await?;
223 let missing = agent::missing_programs(&config.agents);
224 if !missing.is_empty() {
225 bail!(
226 "these agent programs are not on PATH: {}. Fix the roster in \
227 magi.toml or install them.",
228 missing.join(", ")
229 );
230 }
231 if !git::branch_exists(&repo, branch).await? {
232 bail!("no branch `{branch}` in {}", repo.display());
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 if base_branch == branch {
241 bail!("`{branch}` is the base branch; there is nothing to review against");
242 }
243 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
244
245 let roles = config.resolve_roles()?;
246 let max_parallel = config.graph.max_parallel.max(1);
247 let log = git::log_oneline(&repo, &base_commit, branch)
250 .await
251 .unwrap_or_default();
252 let instruction = format!(
253 "Review the work already on branch `{branch}`. There is no task \
254 statement: what the change claims to do is whatever its commits \
255 say.\n\n{}",
256 if log.trim().is_empty() {
257 "(no commit messages)"
258 } else {
259 log.trim()
260 }
261 );
262 let mut state = RunState::new(
263 repo.clone(),
264 base_branch,
265 base_commit.clone(),
266 instruction,
267 config,
268 );
269
270 let worktree = state.worktree_root().join("under-review");
273 if let Some(parent) = worktree.parent() {
274 tokio::fs::create_dir_all(parent).await.ok();
275 }
276 let path = worktree.to_string_lossy().to_string();
277 git::git(&repo, &["worktree", "add", &path, branch])
278 .await
279 .with_context(|| {
280 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
281 })?;
282
283 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
284 .await
285 .unwrap_or(0);
286 if commits == 0 {
287 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
288 }
289 let files = git::changed_files(&worktree, &base_commit, "HEAD")
290 .await
291 .map(|f| f.len())
292 .unwrap_or(0);
293 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
294 .await
295 .unwrap_or_default();
296
297 state.candidates.push(Candidate {
298 index: 0,
299 label: 'A',
300 agent: "(existing branch)".to_owned(),
303 branch: branch.to_owned(),
304 worktree,
305 summary: String::new(),
306 stat,
307 files,
308 commits,
309 empty: false,
310 failed: None,
311 duration_ms: 0,
312 folded: false,
313 });
314 state.tally = Some(Tally {
315 first_choice: BTreeMap::from([('A', 0)]),
316 borda: BTreeMap::new(),
317 winner: 'A',
318 rankings: 0,
319 unanimous_initial: false,
320 deliberated: false,
321 changed_votes: 0,
322 unanimous_final: false,
323 tie_break: Some("review-only run: nothing competed".to_owned()),
324 judges: 0,
328 present: 0,
329 quorum: 0,
330 met_quorum: true,
331 });
332 state.status = RunStatus::Reviewing;
333 state.event(
334 "start",
335 format!(
336 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
337 state.id
338 ),
339 );
340 state.save()?;
341 Ok(Self {
342 state,
343 roles,
344 sem: Arc::new(Semaphore::new(max_parallel)),
345 pause: Pause::new(),
346 })
347 }
348
349 pub fn resume(id: &str) -> Result<Self> {
351 let state = RunState::load(id)?;
352 let roles = state.config.resolve_roles()?;
353 let max_parallel = state.config.graph.max_parallel.max(1);
354 Ok(Self {
355 state,
356 roles,
357 sem: Arc::new(Semaphore::new(max_parallel)),
358 pause: Pause::new(),
359 })
360 }
361
362 pub async fn execute(&mut self) -> Result<()> {
364 self.state.parked = false;
369 if self.state.status == RunStatus::Stalled {
382 if self.recover_stall().await? {
383 self.finish_after_tally().await?;
384 } else {
385 self.state.save()?;
387 }
388 return Ok(());
389 }
390 self.prep().await?;
391 if self.park_here()? {
392 return Ok(());
393 }
394 self.implement().await?;
395 if self.park_here()? {
396 return Ok(());
397 }
398 self.judge().await?;
399 if self.park_here()? {
400 return Ok(());
401 }
402 self.deliberate().await?;
403 if self.park_here()? {
404 return Ok(());
405 }
406 self.vote().await?;
407 if self.park_here()? {
408 return Ok(());
409 }
410 self.tally()?;
411 if self.state.status == RunStatus::Stalled {
416 self.state.save()?;
420 return Ok(());
421 }
422 self.finish_after_tally().await?;
423 Ok(())
424 }
425
426 fn park_here(&mut self) -> Result<bool> {
433 if !self.pause.parked() {
434 return Ok(false);
435 }
436 self.state.event(
437 "park",
438 format!(
439 "parked after `{}` — resume to carry on from here",
440 self.state.status.as_str()
441 ),
442 );
443 self.state.parked = true;
444 self.state.save()?;
445 Ok(true)
446 }
447
448 pub fn on_pause(&mut self, pause: Pause) {
450 self.pause = pause;
451 }
452
453 async fn finish_after_tally(&mut self) -> Result<()> {
456 self.fold_losers().await?;
457 self.review_loop().await?;
458 self.gate().await?;
459 self.merge().await?;
460 self.state.save()?;
461 Ok(())
462 }
463
464 async fn prep(&mut self) -> Result<()> {
467 if !self.state.candidates.is_empty() {
468 return Ok(());
469 }
470 self.state.status = RunStatus::Prep;
471 let repo = self.state.repo.clone();
472 let base = self.state.base_commit.clone();
473 let root = self.state.worktree_root();
474 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
475
476 let hooks_dir = self.state.dir().join("hooks");
479 if self.state.config.blind.commit_msg_hook {
480 std::fs::create_dir_all(&hooks_dir)
481 .with_context(|| format!("create {}", hooks_dir.display()))?;
482 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
483 let path = hooks_dir.join("commit-msg");
484 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
485 make_executable(&path)?;
486 if git::enable_worktree_config(&repo).await? {
487 self.state.enabled_worktree_config = true;
488 }
489 }
490
491 for (index, (spec, label)) in self
492 .roles
493 .implementers
494 .clone()
495 .into_iter()
496 .zip(labels)
497 .enumerate()
498 {
499 let branch = self.state.branch_for(label);
500 let worktree = root.join(format!("cand-{label}"));
501 git::worktree_add_branch(&repo, &worktree, &branch, &base).await?;
502 if self.state.config.blind.commit_msg_hook {
503 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
504 }
505 git::local_exclude(&worktree, "/.magi/").await?;
506 self.state.candidates.push(Candidate {
507 index,
508 label,
509 agent: spec.id.clone(),
510 branch,
511 worktree,
512 summary: String::new(),
513 stat: String::new(),
514 files: 0,
515 commits: 0,
516 empty: false,
517 failed: None,
518 duration_ms: 0,
519 folded: false,
520 });
521 }
522
523 for j in 1..=self.roles.judges.len() {
524 let wt = root.join(format!("judge-{j}"));
525 if !wt.exists() {
526 git::worktree_add_detached(&repo, &wt, &base).await?;
527 }
528 }
529
530 let authors: Vec<&str> = self
535 .roles
536 .implementers
537 .iter()
538 .map(|a| a.id.as_str())
539 .collect();
540 let overlap: Vec<String> = self
541 .roles
542 .judges
543 .iter()
544 .enumerate()
545 .filter(|(_, j)| authors.contains(&j.id.as_str()))
546 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
547 .collect();
548 if !overlap.is_empty() {
549 let note = format!(
550 "{} also authored a candidate; blind, but the panel is less \
551 independent than {} distinct agents would be",
552 overlap.join(", "),
553 self.roles.judges.len()
554 );
555 self.state.event("prep", note);
556 }
557
558 self.state.event(
559 "prep",
560 format!(
561 "{} candidates, {} judges, base {} ({})",
562 self.state.candidates.len(),
563 self.roles.judges.len(),
564 &self.state.base_commit[..7.min(self.state.base_commit.len())],
565 self.state.base_branch
566 ),
567 );
568 self.state.status = RunStatus::Implementing;
569 self.state.save()?;
570 Ok(())
571 }
572
573 async fn implement(&mut self) -> Result<()> {
576 let run_id = self.state.id.clone();
581 let prompts = self.state.config.prompts.clone();
582 let todo: Vec<usize> = self
583 .state
584 .candidates
585 .iter()
586 .enumerate()
587 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
588 .map(|(i, _)| i)
589 .collect();
590 if todo.is_empty() {
591 return self.after_implement();
592 }
593 self.state.status = RunStatus::Implementing;
594
595 let language = self.state.config.graph.language.clone();
596 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
597 let sessions = self.state.config.graph.sessions;
598 let artifacts = agent::artifacts_dir(&self.state.dir());
599
600 let mut jobs = Vec::new();
601 for &i in &todo {
602 let (index, label, worktree) = {
603 let c = &self.state.candidates[i];
604 (c.index, c.label, c.worktree.clone())
605 };
606 let spec = self.roles.implementers[index].clone();
607 let seat_key = format!("impl-{label}");
608 let seat = self.seat(&seat_key, &spec.id);
609 let instruction = self.state.instruction.clone();
610 jobs.push(SeatJob {
611 spec,
612 seat,
613 prompt: prompt::implement(&instruction, &worktree.to_string_lossy(), &language),
614 cwd: worktree,
615 timeout,
616 allow_write: true,
617 sessions,
618 artifacts: artifacts.clone(),
619 stem: format!("impl-{label}"),
620 });
621 }
622
623 self.state.event(
624 "implement",
625 format!("{} candidates in parallel", jobs.len()),
626 );
627 let results = wave(jobs, Arc::clone(&self.sem), &run_id, "implement", &prompts).await;
628
629 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
630 let seat_key = seat.key.clone();
631 self.state.seats.insert(seat.key.clone(), seat);
632 let label = self.state.candidates[i].label;
633 let worktree = self.state.candidates[i].worktree.clone();
634 let base = self.state.base_commit.clone();
635
636 let (summary, duration, failed) = match out {
637 AgentOutcome::Ok(o) => {
638 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
639 let failed = (!o.usable()).then(|| {
640 if o.timed_out {
641 "agent timed out".to_owned()
642 } else {
643 format!("agent exited with {:?}", o.exit_code)
644 }
645 });
646 (text, o.duration_ms, failed)
647 }
648 AgentOutcome::Quota(o) => {
649 self.state.quota.push(QuotaLoss {
650 seat: seat_key,
651 node: "implement".to_owned(),
652 at: Timestamp::now(),
653 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
654 });
655 (
656 String::new(),
657 o.duration_ms,
658 Some("rate limited (quota); produced no change".to_owned()),
659 )
660 }
661 AgentOutcome::Failed(e) => (String::new(), 0, Some(e)),
662 };
663
664 let rescued = git::commit_all(
667 &worktree,
668 &format!("magi: candidate {label} (uncommitted work)"),
669 )
670 .await
671 .unwrap_or(false);
672 let commits = git::commits_ahead(&worktree, &base, "HEAD")
673 .await
674 .unwrap_or(0);
675 let patch = git::diff(&worktree, &base, "HEAD")
676 .await
677 .unwrap_or_default();
678 let stat = git::diff_stat(&worktree, &base, "HEAD")
679 .await
680 .unwrap_or_default();
681 let files = git::changed_files(&worktree, &base, "HEAD")
682 .await
683 .map(|f| f.len())
684 .unwrap_or(0);
685 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
686
687 let c = &mut self.state.candidates[i];
688 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
689 c.stat = stat;
690 c.files = files;
691 c.commits = commits;
692 c.duration_ms = duration;
693 c.empty = commits == 0 || patch.trim().is_empty();
694 c.failed = match failed {
697 Some(_) if c.empty => failed,
698 _ => None,
699 };
700 let note = match (&c.failed, c.empty, rescued) {
701 (Some(e), _, _) => format!("candidate {label}: {e}"),
702 (None, true, _) => format!("candidate {label}: no change produced"),
703 (None, false, true) => {
704 format!(
705 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
706 )
707 }
708 (None, false, false) => {
709 format!("candidate {label}: {files} files, {commits} commits")
710 }
711 };
712 self.state.event("implement", note);
713 self.state.save()?;
714 }
715
716 self.after_implement()
717 }
718
719 fn after_implement(&mut self) -> Result<()> {
720 if self.state.leaks.is_empty() {
722 let cfg = self.state.config.blind.clone();
723 let mut leaks = Vec::new();
724 for c in &self.state.candidates {
725 let Some(patch) =
726 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
727 else {
728 continue;
729 };
730 leaks.extend(blind::scan(
731 &format!("candidate {} patch", c.label),
732 &patch,
733 &cfg.vendor_tokens,
734 ));
735 }
736 if !leaks.is_empty() {
737 let summary = leaks
738 .iter()
739 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
740 .collect::<Vec<_>>()
741 .join(", ");
742 match cfg.on_leak {
743 LeakPolicy::Fail => {
744 self.state.status = RunStatus::Failed;
745 self.state
746 .event("blind", format!("vendor text in a patch: {summary}"));
747 self.state.leaks = leaks;
748 self.state.save()?;
749 bail!(
750 "blind.on_leak = \"fail\" and vendor text reached a \
751 judged patch: {summary}"
752 );
753 }
754 LeakPolicy::Redact => self.state.event(
755 "blind",
756 format!("redacting vendor text for judging: {summary}"),
757 ),
758 LeakPolicy::Warn => self.state.event(
759 "blind",
760 format!("vendor text present in a judged patch (shown as-is): {summary}"),
761 ),
762 }
763 self.state.leaks = leaks;
764 }
765 }
766
767 if self.state.viable().is_empty() {
768 self.state.status = RunStatus::Failed;
769 self.state.save()?;
770 bail!("no candidate produced a change; nothing to judge");
771 }
772 self.state.status = RunStatus::Judging;
773 self.state.save()?;
774 Ok(())
775 }
776
777 async fn judge(&mut self) -> Result<()> {
780 let run_id = self.state.id.clone();
785 let prompts = self.state.config.prompts.clone();
786 if !self.state.judgements.is_empty() {
787 return Ok(());
788 }
789 self.state.status = RunStatus::Judging;
790 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
791 if viable.len() == 1 {
792 self.state.event(
793 "judge",
794 format!(
795 "only candidate {} produced a change; judging skipped",
796 viable[0].label
797 ),
798 );
799 self.state.save()?;
800 return Ok(());
801 }
802
803 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
804 let language = self.state.config.graph.language.clone();
805 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
806 let sessions = self.state.config.graph.sessions;
807 let artifacts = agent::artifacts_dir(&self.state.dir());
808 let root = self.state.worktree_root();
809 let base_short = short(&self.state.base_commit);
810
811 let mut jobs = Vec::new();
812 let mut orders = Vec::new();
813 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
814 let order = blind::presentation_order(viable.len(), j, self.state.seed);
815 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
816 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
817 let seat_key = format!("judge-{}", j + 1);
818 let seat = self.seat(&seat_key, &spec.id);
819 jobs.push(SeatJob {
820 prompt: prompt::judge(
821 &self.state.instruction,
822 &views,
823 self.roles.judges.len(),
824 &base_short,
825 &language,
826 ),
827 spec,
828 seat,
829 cwd: root.join(format!("judge-{}", j + 1)),
830 timeout,
831 allow_write: false,
832 sessions,
833 artifacts: artifacts.clone(),
834 stem: format!("judge-{}", j + 1),
835 });
836 }
837
838 self.state.event(
839 "judge",
840 format!(
841 "{} judges ranking {} candidates blind",
842 jobs.len(),
843 viable.len()
844 ),
845 );
846 let labels_for_check = labels.clone();
847 let mut quota_losses = Vec::new();
848 let results = ask_json_wave::<Ranking>(
849 jobs,
850 Arc::clone(&self.sem),
851 self.state.config.graph.retries,
852 &run_id,
853 "judge",
854 &prompts,
855 &mut quota_losses,
856 &move |r: &Ranking| r.validate(&labels_for_check),
857 )
858 .await;
859 self.state.quota.extend(quota_losses);
860
861 for (j, (seat, res)) in results.into_iter().enumerate() {
862 let agent_id = seat.agent.clone();
863 self.state.seats.insert(seat.key.clone(), seat);
864 let mut record = Judgement {
865 judge: j + 1,
866 seat: format!("judge-{}", j + 1),
867 agent: agent_id,
868 ranking: Vec::new(),
869 reasons: BTreeMap::new(),
870 confidence: None,
871 order: orders[j].clone(),
872 failed: None,
873 duration_ms: 0,
874 };
875 match res {
876 Ok((ranking, out)) => {
877 record.ranking = ranking.normalized();
878 record.reasons = ranking.reasons;
879 record.confidence = ranking.confidence;
880 record.duration_ms = out.duration_ms;
881 self.state.event(
882 "judge",
883 format!(
884 "judge {} ranked {}",
885 j + 1,
886 record.ranking.iter().collect::<String>()
887 ),
888 );
889 }
890 Err(e) => {
891 record.failed = Some(e.to_string());
892 self.state
893 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
894 }
895 }
896 self.state.judgements.push(record);
897 self.state.save()?;
898 }
899 Ok(())
900 }
901
902 async fn deliberate(&mut self) -> Result<()> {
905 let run_id = self.state.id.clone();
910 let prompts = self.state.config.prompts.clone();
911 if !self.state.deliberation.is_empty() {
912 return Ok(());
913 }
914 let tops: Vec<char> = self
915 .state
916 .judgements
917 .iter()
918 .filter_map(|j| j.ranking.first().copied())
919 .collect();
920 let rounds = self.state.config.graph.deliberate_rounds;
921 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
922 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
923 self.state.event(
924 "deliberate",
925 format!("judges agreed on {} outright; no deliberation", tops[0]),
926 );
927 }
928 self.state.status = RunStatus::Voting;
929 self.state.save()?;
930 return Ok(());
931 }
932
933 self.state.status = RunStatus::Deliberating;
934 self.state.event(
935 "deliberate",
936 format!(
937 "split: first choices were {} — opening {rounds} round(s)",
938 tops.iter().collect::<String>()
939 ),
940 );
941
942 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
943 let language = self.state.config.graph.language.clone();
944 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
945 let sessions = self.state.config.graph.sessions;
946 let artifacts = agent::artifacts_dir(&self.state.dir());
947 let root = self.state.worktree_root();
948 let base_short = short(&self.state.base_commit);
949
950 for round in 1..=rounds {
954 let mut turns: Vec<DeliberationTurn> = Vec::new();
955 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
956 if self.state.judgements[j].failed.is_some() {
957 continue;
958 }
959 let seat_key = format!("judge-{}", j + 1);
960 let mut seat = self.seat(&seat_key, &spec.id);
961 let transcript = self.transcript(&turns, j);
962 let context = if has_context(&spec, &seat, sessions) {
963 None
964 } else {
965 Some(self.candidate_block(&viable, &base_short))
966 };
967 let text = prompt::deliberate(
968 &self.state.instruction,
969 context.as_deref(),
970 &transcript,
971 round,
972 rounds,
973 &language,
974 );
975 let job = SeatJob {
976 spec,
977 seat: seat.clone(),
978 prompt: text,
979 cwd: root.join(format!("judge-{}", j + 1)),
980 timeout,
981 allow_write: false,
982 sessions,
983 artifacts: artifacts.clone(),
984 stem: format!("delib-{round}-judge-{}", j + 1),
985 };
986 let (updated, out) =
987 run_one(job, Arc::clone(&self.sem), &run_id, "deliberate", &prompts).await;
988 seat = updated;
989 let agent_id = seat.agent.clone();
990 let seat_key = seat.key.clone();
991 self.state.seats.insert(seat.key.clone(), seat);
992 let body = match out {
993 AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
994 AgentOutcome::Quota(o) => {
995 self.state.quota.push(QuotaLoss {
996 seat: seat_key,
997 node: "deliberate".to_owned(),
998 at: Timestamp::now(),
999 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1000 });
1001 self.state.event(
1002 "deliberate",
1003 format!("judge {} skipped: rate limited (quota)", j + 1),
1004 );
1005 continue;
1006 }
1007 AgentOutcome::Failed(e) => {
1008 self.state
1009 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
1010 continue;
1011 }
1012 };
1013 let tentative = verdict::extract_json::<Position>(&body)
1014 .ok()
1015 .and_then(|p| p.tentative)
1016 .and_then(|s| s.trim().chars().next())
1017 .map(|c| c.to_ascii_uppercase());
1018 self.state.event(
1019 "deliberate",
1020 format!(
1021 "round {round}: judge {} now favours {}",
1022 j + 1,
1023 tentative.map_or("—".to_owned(), |c| c.to_string())
1024 ),
1025 );
1026 turns.push(DeliberationTurn {
1027 judge: j + 1,
1028 agent: agent_id,
1029 body: blind::sanitize_prose(&body, &self.state.config.blind),
1030 tentative,
1031 });
1032 }
1033 self.state
1034 .deliberation
1035 .push(DeliberationRound { round, turns });
1036 self.state.save()?;
1037 }
1038
1039 self.state.status = RunStatus::Voting;
1040 self.state.save()?;
1041 Ok(())
1042 }
1043
1044 async fn vote(&mut self) -> Result<()> {
1047 let run_id = self.state.id.clone();
1052 let prompts = self.state.config.prompts.clone();
1053 if !self.state.votes.is_empty() {
1054 return Ok(());
1055 }
1056 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1057 if viable.len() == 1 {
1058 return Ok(());
1059 }
1060 self.state.status = RunStatus::Voting;
1061
1062 let language = self.state.config.graph.language.clone();
1063 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1064 let sessions = self.state.config.graph.sessions;
1065 let artifacts = agent::artifacts_dir(&self.state.dir());
1066 let root = self.state.worktree_root();
1067 let base_short = short(&self.state.base_commit);
1068 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1069
1070 let mut jobs = Vec::new();
1071 let mut seats_at = Vec::new();
1072 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1073 if self
1074 .state
1075 .judgements
1076 .get(j)
1077 .is_some_and(|r| r.failed.is_some())
1078 {
1079 continue;
1080 }
1081 let seat_key = format!("judge-{}", j + 1);
1082 let seat = self.seat(&seat_key, &spec.id);
1083 let mut text = prompt::final_vote(&viable, &language);
1084 if !has_context(&spec, &seat, sessions) {
1085 text = format!(
1086 "{}\n\n# Candidates\n\n{}",
1087 text,
1088 self.candidate_block(&candidates, &base_short)
1089 );
1090 }
1091 jobs.push(SeatJob {
1092 spec,
1093 seat,
1094 prompt: text,
1095 cwd: root.join(format!("judge-{}", j + 1)),
1096 timeout,
1097 allow_write: false,
1098 sessions,
1099 artifacts: artifacts.clone(),
1100 stem: format!("vote-judge-{}", j + 1),
1101 });
1102 seats_at.push(j);
1103 }
1104
1105 self.state.event(
1106 "vote",
1107 format!(
1108 "collecting {} final votes one by one, privately",
1109 jobs.len()
1110 ),
1111 );
1112 let allowed = viable.clone();
1113 let mut quota_losses = Vec::new();
1114 let results = ask_json_wave::<FinalVote>(
1115 jobs,
1116 Arc::clone(&self.sem),
1117 self.state.config.graph.retries,
1118 &run_id,
1119 "vote",
1120 &prompts,
1121 &mut quota_losses,
1122 &move |v: &FinalVote| match v.label() {
1123 Some(c) if allowed.contains(&c) => Ok(()),
1124 other => bail!("vote {other:?} is not one of {allowed:?}"),
1125 },
1126 )
1127 .await;
1128 self.state.quota.extend(quota_losses);
1129
1130 for (&j, (seat, res)) in seats_at.iter().zip(results) {
1131 let agent_id = seat.agent.clone();
1132 self.state.seats.insert(seat.key.clone(), seat);
1133 let initial = self
1134 .state
1135 .judgements
1136 .get(j)
1137 .and_then(|r| r.ranking.first().copied());
1138 let mut record = VoteRecord {
1139 judge: j + 1,
1140 agent: agent_id,
1141 vote: None,
1142 reason: String::new(),
1143 changed: false,
1144 };
1145 match res {
1146 Ok((v, _)) => {
1147 record.vote = v.label();
1148 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1149 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
1150 self.state.event(
1151 "vote",
1152 format!(
1153 "judge {} voted {}{}",
1154 j + 1,
1155 record.vote.unwrap_or('?'),
1156 if record.changed { " (changed)" } else { "" }
1157 ),
1158 );
1159 }
1160 Err(e) => {
1161 self.state
1162 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
1163 }
1164 }
1165 self.state.votes.push(record);
1166 self.state.save()?;
1167 }
1168 Ok(())
1169 }
1170
1171 fn tally(&mut self) -> Result<()> {
1174 if self.state.tally.is_some() {
1175 return Ok(());
1176 }
1177 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1178 let tops: Vec<char> = self
1179 .state
1180 .judgements
1181 .iter()
1182 .filter_map(|j| j.ranking.first().copied())
1183 .collect();
1184 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
1185
1186 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1189 let mut cast: Vec<char> = Vec::new();
1190 for (i, j) in self.state.judgements.iter().enumerate() {
1191 let vote = self
1192 .state
1193 .votes
1194 .iter()
1195 .find(|v| v.judge == i + 1)
1196 .and_then(|v| v.vote)
1197 .or_else(|| j.ranking.first().copied());
1198 if let Some(v) = vote {
1199 *first_choice.entry(v).or_insert(0) += 1;
1200 cast.push(v);
1201 }
1202 }
1203
1204 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1205 for j in &self.state.judgements {
1206 let n = j.ranking.len();
1207 for (pos, label) in j.ranking.iter().enumerate() {
1208 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
1209 }
1210 }
1211
1212 let best = first_choice.values().copied().max().unwrap_or(0);
1213 let mut leaders: Vec<char> = first_choice
1214 .iter()
1215 .filter(|(_, v)| **v == best)
1216 .map(|(k, _)| *k)
1217 .collect();
1218 let mut tie_break = None;
1219 if leaders.len() > 1 {
1220 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
1221 let borda_leaders: Vec<char> = leaders
1222 .iter()
1223 .copied()
1224 .filter(|l| borda[l] == top_borda)
1225 .collect();
1226 tie_break = Some(if borda_leaders.len() == 1 {
1227 format!(
1228 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
1229 leaders.len()
1230 )
1231 } else {
1232 format!(
1233 "{} way tie on both first-choice votes and Borda points, broken by label order",
1234 leaders.len()
1235 )
1236 });
1237 leaders = borda_leaders;
1238 leaders.sort_unstable();
1239 }
1240 let winner = *leaders
1241 .first()
1242 .or(viable.first())
1243 .context("no candidate to declare a winner from")?;
1244
1245 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
1246 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
1247 let deliberated = !self.state.deliberation.is_empty();
1248
1249 let quota_seats: std::collections::BTreeSet<&str> =
1253 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1254 let mut present = 0usize;
1255 for (i, j) in self.state.judgements.iter().enumerate() {
1256 if quota_seats.contains(j.seat.as_str()) {
1257 continue;
1258 }
1259 let ranked = !j.ranking.is_empty() && j.failed.is_none();
1260 let voted = self
1261 .state
1262 .votes
1263 .iter()
1264 .any(|v| v.judge == i + 1 && v.vote.is_some());
1265 if ranked || voted {
1266 present += 1;
1267 }
1268 }
1269 let judges_total = self.roles.judges.len();
1273 let needs_quorum = viable.len() > 1;
1274 let quorum = if needs_quorum {
1275 judges_total / 2 + 1
1276 } else {
1277 0
1278 };
1279 let met_quorum = !needs_quorum || present >= quorum;
1280
1281 self.state.event(
1282 "tally",
1283 format!(
1284 "winner {winner} — votes {} | initial {} | {} changed | {present}/{judges_total} judges{}",
1285 first_choice
1286 .iter()
1287 .map(|(k, v)| format!("{k}:{v}"))
1288 .collect::<Vec<_>>()
1289 .join(" "),
1290 if unanimous_initial {
1291 "unanimous"
1292 } else {
1293 "split"
1294 },
1295 changed_votes,
1296 if met_quorum {
1297 String::new()
1298 } else {
1299 format!(" — below quorum ({quorum} required)")
1300 },
1301 ),
1302 );
1303 if !met_quorum {
1304 self.state.event(
1305 "stall",
1306 format!(
1307 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
1308 the run stops here, resumable"
1309 ),
1310 );
1311 }
1312 self.state.tally = Some(Tally {
1313 first_choice,
1314 borda,
1315 winner,
1316 rankings: tops.len(),
1317 unanimous_initial,
1318 deliberated,
1319 changed_votes,
1320 unanimous_final,
1321 tie_break,
1322 judges: judges_total,
1323 present,
1324 quorum,
1325 met_quorum,
1326 });
1327 self.state.status = if met_quorum {
1328 RunStatus::Reviewing
1329 } else {
1330 RunStatus::Stalled
1331 };
1332 self.state.save()?;
1333 Ok(())
1334 }
1335
1336 #[allow(clippy::too_many_lines)]
1357 async fn recover_stall(&mut self) -> Result<bool> {
1358 let run_id = self.state.id.clone();
1363 let prompts = self.state.config.prompts.clone();
1364 let quota_seats: BTreeSet<&str> =
1369 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1370 let absent: Vec<String> = self
1371 .state
1372 .judgements
1373 .iter()
1374 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
1375 .map(|j| j.seat.clone())
1376 .collect();
1377 if absent.is_empty() {
1378 return Ok(false);
1379 }
1380 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1381 if viable.len() <= 1 {
1382 return Ok(false);
1383 }
1384 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1385 let language = self.state.config.graph.language.clone();
1386 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1387 let sessions = self.state.config.graph.sessions;
1388 let artifacts = agent::artifacts_dir(&self.state.dir());
1389 let root = self.state.worktree_root();
1390 let base_short = short(&self.state.base_commit);
1391 let candidates: Vec<Candidate> = viable.clone();
1392
1393 let mut positions: Vec<usize> = absent
1395 .iter()
1396 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
1397 .collect();
1398 if positions.is_empty() {
1399 return Ok(false);
1400 }
1401 positions.sort_unstable();
1402 positions.dedup();
1403
1404 let mut judge_jobs = Vec::new();
1406 for &j in &positions {
1407 let order = blind::presentation_order(viable.len(), j, self.state.seed);
1408 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1409 let seat_key = format!("judge-{}", j + 1);
1410 let spec = self.roles.judges[j].clone();
1411 let seat = self.seat(&seat_key, &spec.id);
1412 judge_jobs.push(SeatJob {
1413 spec,
1414 seat,
1415 prompt: prompt::judge(
1416 &self.state.instruction,
1417 &views,
1418 self.roles.judges.len(),
1419 &base_short,
1420 &language,
1421 ),
1422 cwd: root.join(seat_key),
1423 timeout,
1424 allow_write: false,
1425 sessions,
1426 artifacts: artifacts.clone(),
1427 stem: format!("judge-{}-recover", j + 1),
1428 });
1429 }
1430
1431 let labels_for_check = labels.clone();
1432 let mut judge_losses = Vec::new();
1433 let results = ask_json_wave::<Ranking>(
1434 judge_jobs,
1435 Arc::clone(&self.sem),
1436 self.state.config.graph.retries,
1437 &run_id,
1438 "judge",
1439 &prompts,
1440 &mut judge_losses,
1441 &move |r: &Ranking| r.validate(&labels_for_check),
1442 )
1443 .await;
1444
1445 let mut recovered: BTreeSet<usize> = BTreeSet::new();
1447 for (&j, (seat, res)) in positions.iter().zip(results) {
1448 self.state.seats.insert(seat.key.clone(), seat);
1449 let record = &mut self.state.judgements[j];
1450 match res {
1451 Ok((ranking, out)) => {
1452 record.ranking = ranking.normalized();
1453 record.reasons = ranking.reasons;
1454 record.confidence = ranking.confidence;
1455 record.failed = None;
1456 record.duration_ms = out.duration_ms;
1457 recovered.insert(j);
1458 self.state.event(
1459 "recover",
1460 format!("judge {} ranked again after the limit", j + 1),
1461 );
1462 }
1463 Err(e) => {
1464 self.state
1465 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
1466 }
1467 }
1468 }
1469
1470 let mut vote_jobs = Vec::new();
1472 let mut vote_pos: Vec<usize> = Vec::new();
1473 for &j in &recovered {
1474 let seat_key = format!("judge-{}", j + 1);
1475 let spec = self.roles.judges[j].clone();
1476 let seat = self.seat(&seat_key, &spec.id);
1477 let mut text = prompt::final_vote(&labels, &language);
1478 if !has_context(&spec, &seat, sessions) {
1479 text = format!(
1480 "{}\n\n# Candidates\n\n{}",
1481 text,
1482 self.candidate_block(&candidates, &base_short)
1483 );
1484 }
1485 vote_jobs.push(SeatJob {
1486 spec,
1487 seat,
1488 prompt: text,
1489 cwd: root.join(seat_key),
1490 timeout,
1491 allow_write: false,
1492 sessions,
1493 artifacts: artifacts.clone(),
1494 stem: format!("vote-judge-{}-recover", j + 1),
1495 });
1496 vote_pos.push(j);
1497 }
1498 let allowed = labels.clone();
1499 let mut vote_losses = Vec::new();
1500 let votes = ask_json_wave::<FinalVote>(
1501 vote_jobs,
1502 Arc::clone(&self.sem),
1503 self.state.config.graph.retries,
1504 &run_id,
1505 "vote",
1506 &prompts,
1507 &mut vote_losses,
1508 &move |v: &FinalVote| match v.label() {
1509 Some(c) if allowed.contains(&c) => Ok(()),
1510 other => bail!("vote {other:?} is not one of {allowed:?}"),
1511 },
1512 )
1513 .await;
1514 for (&j, (seat, res)) in vote_pos.iter().zip(votes) {
1515 let agent_id = seat.agent.clone();
1516 self.state.seats.insert(seat.key.clone(), seat);
1517 match res {
1518 Ok((v, _)) => {
1519 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
1520 rec.vote = v.label();
1521 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1522 } else {
1523 self.state.votes.push(VoteRecord {
1524 judge: j + 1,
1525 agent: agent_id,
1526 vote: v.label(),
1527 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
1528 changed: false,
1529 });
1530 }
1531 self.state.event(
1532 "recover",
1533 format!("judge {} voted again after the limit", j + 1),
1534 );
1535 }
1536 Err(e) => {
1537 self.state
1538 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
1539 }
1540 }
1541 }
1542
1543 if !recovered.is_empty() {
1547 let recovered_keys: BTreeSet<String> = recovered
1548 .iter()
1549 .map(|&j| format!("judge-{}", j + 1))
1550 .collect();
1551 self.state
1552 .quota
1553 .retain(|q| !recovered_keys.contains(&q.seat));
1554 }
1555
1556 self.state.tally = None;
1558 self.tally()?;
1559 Ok(self
1560 .state
1561 .tally
1562 .as_ref()
1563 .map(|t| t.met_quorum)
1564 .unwrap_or(false))
1565 }
1566
1567 async fn fold_losers(&mut self) -> Result<()> {
1570 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
1571 return Ok(());
1572 };
1573 let repo = self.state.repo.clone();
1574 let mut folded = Vec::new();
1575 for i in 0..self.state.candidates.len() {
1576 let c = &self.state.candidates[i];
1577 if c.label == winner || c.folded {
1578 continue;
1579 }
1580 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
1581 git::worktree_remove(&repo, &wt).await.ok();
1582 git::branch_delete(&repo, &branch).await.ok();
1583 self.state.candidates[i].folded = true;
1584 folded.push(label.to_string());
1585 }
1586 let root = self.state.worktree_root();
1588 for j in 1..=self.roles.judges.len() {
1589 let wt = root.join(format!("judge-{j}"));
1590 if wt.exists() {
1591 git::worktree_remove(&repo, &wt).await.ok();
1592 }
1593 }
1594 if !folded.is_empty() {
1595 self.state
1596 .event("fold", format!("folded candidates {}", folded.join(", ")));
1597 self.state.save()?;
1598 }
1599 Ok(())
1600 }
1601
1602 async fn review_loop(&mut self) -> Result<()> {
1605 let run_id = self.state.id.clone();
1610 let prompts = self.state.config.prompts.clone();
1611 let Some(winner) = self.state.winner().cloned() else {
1612 return Ok(());
1613 };
1614 let max_rounds = self.state.config.graph.review_rounds;
1615 if max_rounds == 0 || self.state.reviews.iter().any(|r| r.clean) {
1616 self.state.status = RunStatus::Gating;
1617 self.state.save()?;
1618 return Ok(());
1619 }
1620 self.state.status = RunStatus::Reviewing;
1621
1622 let repo = self.state.repo.clone();
1623 let root = self.state.worktree_root();
1624 let language = self.state.config.graph.language.clone();
1625 let sessions = self.state.config.graph.sessions;
1626 let artifacts = agent::artifacts_dir(&self.state.dir());
1627 let base_short = short(&self.state.base_commit);
1628 let reviewers = self.roles.reviewers.clone();
1629 let base = self.state.base_commit.clone();
1630 let shell = self.state.config.shell();
1631
1632 let mut prev_e2e: Option<String> = None;
1633 for round in (self.state.reviews.len() + 1)..=max_rounds {
1634 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
1635 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
1636 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
1637
1638 let mut jobs = Vec::new();
1642 for (r, spec) in reviewers.iter().cloned().enumerate() {
1643 let wt = root.join(format!("review-{}", r + 1));
1644 if wt.exists() {
1645 git::reset_detached(&wt, &head).await?;
1646 } else {
1647 git::worktree_add_detached(&repo, &wt, &head).await?;
1648 }
1649 let seat_key = format!("review-{}", r + 1);
1650 let seat = self.seat(&seat_key, &spec.id);
1651 jobs.push(SeatJob {
1652 prompt: prompt::review(&prompt::ReviewCtx {
1653 instruction: &self.state.instruction,
1654 branch: &winner.branch,
1655 base_short: &base_short,
1656 stat: &stat,
1657 patch: &patch,
1658 e2e: prev_e2e.as_deref(),
1659 reviewers: reviewers.len(),
1660 round,
1661 rounds: max_rounds,
1662 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
1665 language: &language,
1666 }),
1667 spec,
1668 seat,
1669 cwd: wt,
1670 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
1671 allow_write: false,
1672 sessions,
1673 artifacts: artifacts.clone(),
1674 stem: format!("review-{round}-{}", r + 1),
1675 });
1676 }
1677
1678 self.state.event(
1679 "review",
1680 format!(
1681 "round {round}: {} reviewers on {}",
1682 jobs.len(),
1683 short(&head)
1684 ),
1685 );
1686 let mut quota_losses = Vec::new();
1687 let results = ask_json_wave::<Review>(
1688 jobs,
1689 Arc::clone(&self.sem),
1690 self.state.config.graph.retries,
1691 &run_id,
1692 "review",
1693 &prompts,
1694 &mut quota_losses,
1695 &|_: &Review| Ok(()),
1696 )
1697 .await;
1698 self.state.quota.extend(quota_losses);
1699
1700 let mut records = Vec::new();
1701 let mut all_findings = Vec::new();
1702 for (r, (seat, res)) in results.into_iter().enumerate() {
1703 let agent_id = seat.agent.clone();
1704 self.state.seats.insert(seat.key.clone(), seat);
1705 let mut record = ReviewRecord {
1706 reviewer: r + 1,
1707 agent: agent_id,
1708 summary: String::new(),
1709 findings: Vec::new(),
1710 failed: None,
1711 duration_ms: 0,
1712 };
1713 match res {
1714 Ok((review, out)) => {
1715 record.summary = review.summary;
1716 record.duration_ms = out.duration_ms;
1717 for (n, mut f) in review.findings.into_iter().enumerate() {
1718 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
1721 all_findings.push(f.clone());
1722 record.findings.push(f);
1723 }
1724 self.state.event(
1725 "review",
1726 format!(
1727 "round {round}: reviewer {} raised {} finding(s)",
1728 r + 1,
1729 record.findings.len()
1730 ),
1731 );
1732 }
1733 Err(e) => {
1734 record.failed = Some(e.to_string());
1735 self.state.event(
1736 "review",
1737 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
1738 );
1739 }
1740 }
1741 records.push(record);
1742 }
1743
1744 let e2e = run_commands(
1745 &shell,
1746 &self.state.config.verify.e2e,
1747 &winner.worktree,
1748 Duration::from_secs(self.state.config.graph.timeout_review),
1749 )
1750 .await;
1751 for o in &e2e {
1752 self.state.event(
1753 "verify",
1754 format!(
1755 "round {round}: `{}` -> {}",
1756 o.command,
1757 if o.ok() {
1758 "pass".to_owned()
1759 } else {
1760 format!("FAIL ({:?})", o.code)
1761 }
1762 ),
1763 );
1764 }
1765 let e2e_failures: String = e2e
1766 .iter()
1767 .filter(|o| !o.ok())
1768 .map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
1769 .collect();
1770
1771 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
1772 let clean = blocking == 0 && e2e.iter().all(CommandOutcome::ok);
1773
1774 let mut round_record = ReviewRound {
1775 round,
1776 head: head.clone(),
1777 reviews: records,
1778 e2e,
1779 fix: None,
1780 blocking,
1781 clean,
1782 };
1783
1784 if clean {
1785 self.state.event(
1786 "review",
1787 format!("round {round}: clean — no blocking findings, verification green"),
1788 );
1789 self.state.reviews.push(round_record);
1790 self.state.status = RunStatus::Gating;
1791 self.state.save()?;
1792 return Ok(());
1793 }
1794
1795 if round == max_rounds {
1796 self.state.reviews.push(round_record);
1797 self.state.status = RunStatus::Blocked;
1798 self.state.event(
1799 "review",
1800 format!("{blocking} blocking finding(s) still open after {max_rounds} rounds"),
1801 );
1802 self.state.save()?;
1803 return Ok(());
1804 }
1805
1806 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
1809 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1810 _ => (
1811 self.state
1812 .config
1813 .agent(&winner.agent)
1814 .cloned()
1815 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
1816 format!("impl-{}", winner.label),
1817 ),
1818 };
1819 let seat = self.seat(&fix_seat_key, &fix_spec.id);
1820 let blocking_findings: Vec<_> = all_findings
1821 .iter()
1822 .filter(|f| f.severity.blocks())
1823 .cloned()
1824 .collect();
1825 let job = SeatJob {
1826 prompt: prompt::fix(
1827 &self.state.instruction,
1828 &blocking_findings,
1829 (!e2e_failures.is_empty()).then_some(e2e_failures.as_str()),
1830 round,
1831 max_rounds,
1832 &language,
1833 ),
1834 spec: fix_spec.clone(),
1835 seat,
1836 cwd: winner.worktree.clone(),
1837 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
1838 allow_write: true,
1839 sessions,
1840 artifacts: artifacts.clone(),
1841 stem: format!("fix-{round}"),
1842 };
1843 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
1844 let (seat, out) = run_one(job, Arc::clone(&self.sem), &run_id, "fix", &prompts).await;
1845 let agent_id = seat.agent.clone();
1846 let seat_key = seat.key.clone();
1847 self.state.seats.insert(seat.key.clone(), seat);
1848
1849 let mut fix = FixRecord {
1850 agent: agent_id,
1851 addressed: Vec::new(),
1852 rejected: Vec::new(),
1853 notes: String::new(),
1854 committed: false,
1855 failed: None,
1856 duration_ms: 0,
1857 };
1858 match out {
1859 AgentOutcome::Ok(o) => {
1860 fix.duration_ms = o.duration_ms;
1861 match verdict::extract_json::<FixReport>(&o.text) {
1862 Ok(report) => {
1863 fix.addressed = report.addressed;
1864 fix.rejected = report.rejected;
1865 fix.notes =
1866 blind::sanitize_prose(&report.notes, &self.state.config.blind);
1867 }
1868 Err(e) => fix.failed = Some(format!("unparsable fix report: {e}")),
1869 }
1870 }
1871 AgentOutcome::Quota(o) => {
1872 self.state.quota.push(QuotaLoss {
1873 seat: seat_key,
1874 node: "fix".to_owned(),
1875 at: Timestamp::now(),
1876 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1877 });
1878 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
1879 }
1880 AgentOutcome::Failed(e) => fix.failed = Some(e),
1881 }
1882 git::commit_all(
1883 &winner.worktree,
1884 &format!("magi: review round {round} fixes (uncommitted work)"),
1885 )
1886 .await
1887 .ok();
1888 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
1889 fix.committed = after != before;
1890 self.state.event(
1891 "fix",
1892 format!(
1893 "round {round}: {} addressed, {} rejected, {}",
1894 fix.addressed.len(),
1895 fix.rejected.len(),
1896 if fix.committed {
1897 "committed"
1898 } else {
1899 "NO new commit"
1900 }
1901 ),
1902 );
1903 let stalled = !fix.committed;
1904 round_record.fix = Some(fix);
1905 self.state.reviews.push(round_record);
1906 self.state.save()?;
1907
1908 prev_e2e = (!e2e_failures.is_empty()).then_some(e2e_failures);
1909
1910 if stalled {
1911 self.state.status = RunStatus::Blocked;
1912 self.state.event(
1913 "review",
1914 "the fixer produced no commit; stopping instead of looping on an unchanged tree"
1915 .to_owned(),
1916 );
1917 self.state.save()?;
1918 return Ok(());
1919 }
1920 }
1921 Ok(())
1922 }
1923
1924 async fn gate(&mut self) -> Result<()> {
1927 if self.state.status == RunStatus::Blocked || self.state.status == RunStatus::Failed {
1928 return Ok(());
1929 }
1930 if !self.state.gate.is_empty() {
1931 return Ok(());
1932 }
1933 let Some(winner) = self.state.winner().cloned() else {
1934 return Ok(());
1935 };
1936 self.state.status = RunStatus::Gating;
1937 let shell = self.state.config.shell();
1938 let outcomes = run_commands(
1939 &shell,
1940 &self.state.config.verify.gate,
1941 &winner.worktree,
1942 Duration::from_secs(self.state.config.graph.timeout_review),
1943 )
1944 .await;
1945 for o in &outcomes {
1946 self.state.event(
1947 "gate",
1948 format!(
1949 "`{}` -> {}",
1950 o.command,
1951 if o.ok() {
1952 "pass".to_owned()
1953 } else {
1954 format!("FAIL ({:?})", o.code)
1955 }
1956 ),
1957 );
1958 }
1959 let passed = outcomes.iter().all(CommandOutcome::ok);
1960 self.state.gate = outcomes;
1961 if !passed {
1962 self.state.status = RunStatus::Blocked;
1963 self.state.event("gate", "gate failed; not merging");
1964 }
1965 self.state.save()?;
1966 Ok(())
1967 }
1968
1969 async fn merge(&mut self) -> Result<()> {
1972 if self.state.status.done() && self.state.status != RunStatus::Ready {
1973 return Ok(());
1974 }
1975 let Some(winner) = self.state.winner().cloned() else {
1976 return Ok(());
1977 };
1978 let repo = self.state.repo.clone();
1979 let base = self.state.base_branch.clone();
1980 let mode = self.state.config.merge.mode;
1981 let message = format!(
1982 "Merge magi run {} (candidate {})\n\n{}",
1983 self.state.id, winner.label, self.state.instruction
1984 );
1985
1986 let outcome = match mode {
1987 MergeMode::None => MergeOutcome {
1988 mode,
1989 ok: true,
1990 detail: format!("git -C {} merge --no-ff {}", repo.display(), winner.branch),
1991 },
1992 MergeMode::Local => {
1993 let on = git::current_branch(&repo).await?;
1994 if on.as_deref() != Some(base.as_str()) {
1995 MergeOutcome {
1996 mode,
1997 ok: false,
1998 detail: format!(
1999 "{} has {} checked out, not the base branch {base}",
2000 repo.display(),
2001 on.unwrap_or_else(|| "a detached HEAD".to_owned())
2002 ),
2003 }
2004 } else if !git::is_clean(&repo).await? {
2005 MergeOutcome {
2006 mode,
2007 ok: false,
2008 detail: format!("{} is dirty; refusing to merge", repo.display()),
2009 }
2010 } else {
2011 let out = git::merge_no_ff(&repo, &winner.branch, &message).await?;
2012 MergeOutcome {
2013 mode,
2014 ok: out.ok(),
2015 detail: if out.ok() { out.stdout } else { out.stderr },
2016 }
2017 }
2018 }
2019 MergeMode::Pr => {
2020 let remote = self.state.config.merge.remote.clone();
2021 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
2022 if !pushed.ok() {
2023 MergeOutcome {
2024 mode,
2025 ok: false,
2026 detail: pushed.stderr,
2027 }
2028 } else {
2029 let out = gh_pr_create(&winner.worktree, &base, &winner.branch, &message).await;
2030 match out {
2031 Ok(url) => MergeOutcome {
2032 mode,
2033 ok: true,
2034 detail: url,
2035 },
2036 Err(e) => MergeOutcome {
2037 mode,
2038 ok: false,
2039 detail: e.to_string(),
2040 },
2041 }
2042 }
2043 }
2044 };
2045
2046 self.state.status = match (mode, outcome.ok) {
2047 (MergeMode::None, _) => RunStatus::Ready,
2048 (_, true) => RunStatus::Merged,
2049 (_, false) => RunStatus::Blocked,
2050 };
2051 self.state.event(
2052 "merge",
2053 format!(
2054 "{:?}: {}",
2055 mode,
2056 outcome.detail.lines().next().unwrap_or("")
2057 ),
2058 );
2059 self.state.merge = Some(outcome);
2060 self.state.save()?;
2061
2062 if self.state.config.graph.land
2068 && mode == MergeMode::Pr
2069 && self.state.status == RunStatus::Merged
2070 {
2071 let url = self
2072 .state
2073 .merge
2074 .as_ref()
2075 .map(|m| m.detail.clone())
2076 .unwrap_or_default();
2077 let url = url.lines().next().unwrap_or("").trim().to_owned();
2078 if url.starts_with("http") {
2079 match land::land(&mut self.state, &url).await {
2082 Ok(pr) => {
2083 self.state.status = match pr.state {
2084 land::PrLifecycle::Merged => RunStatus::Merged,
2085 _ => RunStatus::Blocked,
2086 };
2087 }
2088 Err(e) => {
2089 self.state.status = RunStatus::Blocked;
2090 self.state.event("land", format!("gave up: {e}"));
2091 }
2092 }
2093 self.state.save()?;
2094 }
2095 }
2096 Ok(())
2097 }
2098
2099 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
2103 if let Some(existing) = self.state.seats.get(key)
2104 && existing.agent == agent
2105 {
2106 return existing.clone();
2107 }
2108 let fresh = SeatState::new(key, agent, self.state.seed);
2109 self.state.seats.insert(key.to_owned(), fresh.clone());
2110 fresh
2111 }
2112
2113 fn view(&self, c: &Candidate) -> CandidateView {
2115 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2116 .unwrap_or_default();
2117 let (patch, _) = blind::sanitize_patch(
2118 &format!("candidate {} patch", c.label),
2119 &raw,
2120 &self.state.config.blind,
2121 );
2122 CandidateView {
2123 label: c.label,
2124 branch: c.branch.clone(),
2125 summary: c.summary.clone(),
2126 stat: c.stat.clone(),
2127 patch,
2128 }
2129 }
2130
2131 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
2133 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
2134 prompt::judge(
2135 "(see above)",
2136 &views,
2137 self.roles.judges.len(),
2138 base_short,
2139 "en",
2140 )
2141 }
2142
2143 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
2150 let mut turns = Vec::new();
2151 for j in &self.state.judgements {
2152 if j.ranking.is_empty() {
2153 continue;
2154 }
2155 let reasons = j
2156 .reasons
2157 .iter()
2158 .map(|(k, v)| format!("- {k}: {v}"))
2159 .collect::<Vec<_>>()
2160 .join("\n");
2161 turns.push(Turn {
2162 who: format!("Judge {} (opening ranking)", j.judge),
2163 is_self: j.judge == self_idx + 1,
2164 body: format!(
2165 "Ranked {}{}{reasons}",
2166 j.ranking.iter().collect::<String>(),
2167 if reasons.is_empty() {
2168 ""
2169 } else {
2170 ", because:\n"
2171 }
2172 ),
2173 });
2174 }
2175 for t in self
2176 .state
2177 .deliberation
2178 .iter()
2179 .flat_map(|r| r.turns.iter())
2180 .chain(current)
2181 {
2182 turns.push(Turn {
2183 who: format!("Judge {}", t.judge),
2184 is_self: t.judge == self_idx + 1,
2185 body: t.body.clone(),
2186 });
2187 }
2188 turns
2189 }
2190}
2191
2192fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
2194 agent::has_session(spec.kind, seat, sessions)
2195}
2196
2197fn short(commit: &str) -> String {
2198 commit.chars().take(7).collect()
2199}
2200
2201fn make_executable(path: &Path) -> Result<()> {
2202 #[cfg(unix)]
2203 {
2204 use std::os::unix::fs::PermissionsExt as _;
2205 let mut perms = std::fs::metadata(path)?.permissions();
2206 perms.set_mode(0o755);
2207 std::fs::set_permissions(path, perms)?;
2208 }
2209 #[cfg(not(unix))]
2210 {
2211 let _ = path;
2212 }
2213 Ok(())
2214}
2215
2216async fn run_one(
2218 job: SeatJob,
2219 sem: Arc<Semaphore>,
2220 run: &str,
2221 node: &str,
2222 prompts: &Prompts,
2223) -> (SeatState, AgentOutcome) {
2224 let (_, seat, out) = wave(vec![job], sem, run, node, prompts)
2225 .await
2226 .pop()
2227 .expect("one job in, one result out");
2228 (seat, out)
2229}
2230
2231async fn wave(
2238 jobs: Vec<SeatJob>,
2239 sem: Arc<Semaphore>,
2240 run: &str,
2241 node: &str,
2242 prompts: &Prompts,
2243) -> Vec<(usize, SeatState, AgentOutcome)> {
2244 let mut set = tokio::task::JoinSet::new();
2245 let overlay = prompts.overlay(node);
2246 for (i, mut job) in jobs.into_iter().enumerate() {
2247 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
2248 let sem = Arc::clone(&sem);
2249 let run = run.to_owned();
2250 let node = node.to_owned();
2251 set.spawn(async move {
2252 let _permit = sem.acquire().await;
2253 let mut seat = job.seat;
2254 let out = agent::invoke(
2255 &job.spec,
2256 &mut seat,
2257 &Invocation {
2258 cwd: &job.cwd,
2259 prompt: &job.prompt,
2260 timeout: job.timeout,
2261 allow_write: job.allow_write,
2262 sessions: job.sessions,
2263 artifacts: &job.artifacts,
2264 stem: &job.stem,
2265 run: &run,
2266 node: &node,
2267 },
2268 )
2269 .await;
2270 let out = match out {
2271 Ok(o) if o.usable() => AgentOutcome::Ok(o),
2272 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
2273 Ok(o) if o.timed_out => AgentOutcome::Failed("timed out".to_owned()),
2274 Ok(o) => AgentOutcome::Failed(format!(
2275 "exited with {:?} and no usable output",
2276 o.exit_code
2277 )),
2278 Err(e) => AgentOutcome::Failed(e.to_string()),
2279 };
2280 (i, seat, out)
2281 });
2282 }
2283 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
2284 while let Some(joined) = set.join_next().await {
2285 let (i, seat, out) = match joined {
2286 Ok(v) => v,
2287 Err(e) => {
2288 tracing::error!("agent task panicked: {e}");
2289 continue;
2290 }
2291 };
2292 if collected.len() <= i {
2293 collected.resize_with(i + 1, || None);
2294 }
2295 collected[i] = Some((i, seat, out));
2296 }
2297 collected.into_iter().flatten().collect()
2298}
2299
2300fn retry_budget(full: Duration, nudged: bool) -> Duration {
2315 if nudged {
2316 (full / 4).max(Duration::from_secs(120)).min(full)
2317 } else {
2318 full
2319 }
2320}
2321
2322#[allow(clippy::too_many_arguments)]
2335async fn ask_json_wave<T>(
2336 jobs: Vec<SeatJob>,
2337 sem: Arc<Semaphore>,
2338 retries: usize,
2339 run: &str,
2340 node: &str,
2341 prompts: &Prompts,
2342 losses: &mut Vec<QuotaLoss>,
2343 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
2344) -> Vec<(SeatState, Result<(T, AgentOutput)>)>
2345where
2346 T: serde::de::DeserializeOwned + Send + 'static,
2347{
2348 let n = jobs.len();
2349 let originals: Vec<SeatJob> = jobs;
2350 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
2351 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
2352 let mut pending: Vec<usize> = (0..n).collect();
2353
2354 for attempt in 0..=retries {
2355 if pending.is_empty() {
2356 break;
2357 }
2358 let mut batch = Vec::with_capacity(pending.len());
2359 for &i in &pending {
2360 let src = &originals[i];
2361 let (prompt, timeout) = if attempt == 0 {
2364 (src.prompt.clone(), src.timeout)
2365 } else {
2366 let why = done[i]
2367 .as_ref()
2368 .and_then(|r| r.as_ref().err().map(ToString::to_string))
2369 .unwrap_or_else(|| "no parsable answer".to_owned());
2370 let nudge = prompt::nudge(&why);
2371 let nudged = has_context(&src.spec, &seats[i], src.sessions);
2372 let prompt = if nudged {
2373 nudge
2374 } else {
2375 format!("{}\n\n---\n\n{}", src.prompt, nudge)
2376 };
2377 (prompt, retry_budget(src.timeout, nudged))
2378 };
2379 batch.push(SeatJob {
2380 spec: src.spec.clone(),
2381 seat: seats[i].clone(),
2382 cwd: src.cwd.clone(),
2383 prompt,
2384 timeout,
2385 allow_write: src.allow_write,
2386 sessions: src.sessions,
2387 artifacts: src.artifacts.clone(),
2388 stem: if attempt == 0 {
2389 src.stem.clone()
2390 } else {
2391 format!("{}-retry{attempt}", src.stem)
2392 },
2393 });
2394 }
2395
2396 let results = wave(batch, Arc::clone(&sem), run, node, prompts).await;
2397 let mut still = Vec::new();
2398 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
2399 seats[i] = seat;
2400 let (parsed, quota) = match out {
2401 AgentOutcome::Ok(o) => (
2402 match verdict::extract_json::<T>(&o.text) {
2403 Ok(v) => match validate(&v) {
2404 Ok(()) => Ok((v, o)),
2405 Err(e) => Err(e),
2406 },
2407 Err(e) => Err(e),
2408 },
2409 false,
2410 ),
2411 AgentOutcome::Quota(o) => {
2412 losses.push(QuotaLoss {
2413 seat: originals[i].seat.key.clone(),
2414 node: node.to_owned(),
2415 at: Timestamp::now(),
2416 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2417 });
2418 (
2419 Err(anyhow::anyhow!("rate limited (quota); not retrying now")),
2420 true,
2421 )
2422 }
2423 AgentOutcome::Failed(e) => (Err(anyhow::anyhow!(e)), false),
2424 };
2425 let failed = parsed.is_err();
2426 done[i] = Some(parsed);
2427 if failed && !quota {
2430 still.push(i);
2431 }
2432 }
2433 pending = still;
2434 }
2435
2436 seats
2437 .into_iter()
2438 .zip(done)
2439 .map(|(seat, res)| {
2440 (
2441 seat,
2442 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
2443 )
2444 })
2445 .collect()
2446}
2447
2448async fn run_commands(
2450 shell: &[String],
2451 commands: &[String],
2452 cwd: &Path,
2453 timeout: Duration,
2454) -> Vec<CommandOutcome> {
2455 let mut out = Vec::new();
2456 for command in commands {
2457 let started = Instant::now();
2458 let mut cmd = tokio::process::Command::new(&shell[0]);
2459 cmd.args(&shell[1..])
2460 .arg(command)
2461 .current_dir(cwd)
2462 .stdin(std::process::Stdio::null())
2463 .stdout(std::process::Stdio::piped())
2464 .stderr(std::process::Stdio::piped())
2465 .kill_on_drop(true);
2466 let spawned = cmd.spawn();
2467 let (code, body) = match spawned {
2468 Ok(child) => match tokio::time::timeout(timeout, child.wait_with_output()).await {
2469 Ok(Ok(o)) => {
2470 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
2471 body.push_str(&String::from_utf8_lossy(&o.stderr));
2472 (o.status.code(), body)
2473 }
2474 Ok(Err(e)) => (None, format!("failed to run: {e}")),
2475 Err(_) => (None, format!("timed out after {}s", timeout.as_secs())),
2476 },
2477 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
2478 };
2479 out.push(CommandOutcome {
2480 command: command.clone(),
2481 code,
2482 output_tail: tail(&body, OUTPUT_TAIL),
2483 duration_ms: started.elapsed().as_millis() as u64,
2484 });
2485 }
2486 out
2487}
2488
2489async fn gh_pr_create(cwd: &Path, base: &str, head: &str, body: &str) -> Result<String> {
2491 let title = body.lines().next().unwrap_or("magi run").to_owned();
2492 let out = tokio::process::Command::new("gh")
2493 .args([
2494 "pr", "create", "--base", base, "--head", head, "--title", &title, "--body", body,
2495 ])
2496 .current_dir(cwd)
2497 .stdin(std::process::Stdio::null())
2498 .output()
2499 .await
2500 .context("spawn gh")?;
2501 if out.status.success() {
2502 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2503 } else {
2504 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
2505 }
2506}
2507
2508pub async fn fold_run(state: &mut RunState, drop_winner: bool) -> Result<Vec<String>> {
2510 let repo = state.repo.clone();
2511 let root = state.worktree_root();
2512 let winner = state.tally.as_ref().map(|t| t.winner);
2513 let mut removed = Vec::new();
2514
2515 for i in 0..state.candidates.len() {
2516 let c = state.candidates[i].clone();
2517 let is_winner = Some(c.label) == winner;
2518 if is_winner && !drop_winner {
2519 continue;
2520 }
2521 if c.worktree.exists() {
2522 git::worktree_remove(&repo, &c.worktree).await.ok();
2523 removed.push(c.worktree.to_string_lossy().into_owned());
2524 }
2525 if git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
2526 git::branch_delete(&repo, &c.branch).await.ok();
2527 removed.push(c.branch.clone());
2528 }
2529 state.candidates[i].folded = true;
2530 }
2531
2532 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
2533 let path = name.path();
2534 let keep = !drop_winner
2535 && winner.is_some_and(|w| {
2536 path.file_name()
2537 .is_some_and(|n| n == format!("cand-{w}").as_str())
2538 });
2539 if keep {
2540 continue;
2541 }
2542 git::worktree_remove(&repo, &path).await.ok();
2543 removed.push(path.to_string_lossy().into_owned());
2544 }
2545
2546 if state.enabled_worktree_config && drop_winner {
2547 git::disable_worktree_config(&repo).await.ok();
2548 state.enabled_worktree_config = false;
2549 }
2550 state.save()?;
2551 Ok(removed)
2552}
2553
2554pub fn worst_open(state: &RunState) -> Option<Severity> {
2556 state
2557 .reviews
2558 .last()?
2559 .reviews
2560 .iter()
2561 .flat_map(|r| r.findings.iter())
2562 .map(|f| f.severity)
2563 .max()
2564}
2565
2566#[cfg(test)]
2567mod tests {
2568 use super::retry_budget;
2569 use std::time::Duration;
2570
2571 fn secs(n: u64) -> Duration {
2572 Duration::from_secs(n)
2573 }
2574
2575 #[test]
2576 fn a_nudge_gets_a_quarter_of_the_budget() {
2577 assert_eq!(retry_budget(secs(1200), true), secs(300));
2579 assert_eq!(retry_budget(secs(3600), true), secs(900));
2580 }
2581
2582 #[test]
2583 fn a_resent_prompt_keeps_the_whole_budget() {
2584 assert_eq!(retry_budget(secs(1200), false), secs(1200));
2587 assert_eq!(retry_budget(secs(60), false), secs(60));
2588 }
2589
2590 #[test]
2591 fn the_floor_never_exceeds_the_original_budget() {
2592 assert_eq!(retry_budget(secs(60), true), secs(60));
2596 assert_eq!(retry_budget(secs(480), true), secs(120));
2597 assert_eq!(retry_budget(secs(0), true), secs(0));
2598 }
2599}