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::proc::Quiet as _;
35use crate::prompt::{self, CandidateView, Turn};
36use crate::run::{
37 Candidate, CommandOutcome, DeliberationRound, DeliberationTurn, FixRecord, Judgement,
38 MergeOutcome, QuotaLoss, ReviewRecord, ReviewRound, RunState, RunStatus, Tally, VoteRecord,
39 tail, write_artifact,
40};
41use crate::verdict::{self, FinalVote, FixReport, Position, Ranking, Review, Severity};
42
43const OUTPUT_TAIL: usize = 8_000;
45
46#[derive(Clone)]
52struct SeatJob {
53 spec: AgentSpec,
54 seat: SeatState,
55 cwd: PathBuf,
56 prompt: String,
57 timeout: Duration,
58 allow_write: bool,
59 sessions: bool,
60 artifacts: PathBuf,
61 stem: String,
62}
63
64enum AgentOutcome {
76 Ok(AgentOutput),
78 Quota(AgentOutput),
80 Dropped(AgentOutput),
83 Failed(String),
85}
86
87#[derive(Debug, Clone, Default)]
104pub struct Pause(Arc<AtomicBool>);
105
106impl Pause {
107 #[must_use]
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 pub fn park(&self) {
115 self.0.store(true, Ordering::SeqCst);
116 }
117
118 #[must_use]
120 pub fn parked(&self) -> bool {
121 self.0.load(Ordering::SeqCst)
122 }
123}
124
125pub struct Runner {
127 pub state: RunState,
129 roles: ResolvedRoles,
130 sem: Arc<Semaphore>,
131 pause: Pause,
133}
134
135async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
155 let tracking = format!("{remote}/{base_branch}");
156 let fetched = git::fetch(repo, remote, base_branch).await;
157 if let Ok(out) = &fetched
158 && out.ok()
159 && git::rev_exists(repo, &tracking).await
160 {
161 return git::rev_parse(repo, &tracking).await;
162 }
163 let why = match &fetched {
164 Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
165 Ok(_) => format!("{remote} has no {base_branch}"),
166 Err(e) => e.to_string(),
167 };
168 tracing::warn!(
169 "could not read {tracking} ({why}); branching off the local \
170 {base_branch} instead, which may be behind"
171 );
172 git::rev_parse(repo, base_branch).await.with_context(|| {
173 format!(
174 "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
175 branch that exists"
176 )
177 })
178}
179
180impl Runner {
181 pub async fn start(repo: &Path, instruction: String, config: Config) -> Result<Self> {
183 let repo = git::toplevel(repo).await?;
184 let missing = agent::missing_programs(&config.agents);
185 if !missing.is_empty() {
186 bail!(
187 "these agent programs are not on PATH: {}. Fix the roster in \
188 magi.toml or install them.",
189 missing.join(", ")
190 );
191 }
192 let base_branch = match config.merge.base.clone() {
193 Some(b) => b,
194 None => git::current_branch(&repo)
195 .await?
196 .context("HEAD is detached; set [merge] base in magi.toml")?,
197 };
198 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
199 if !git::is_clean(&repo).await? {
203 tracing::warn!(
204 "{} has uncommitted changes; they are not part of this run, \
205 which branches off {base_branch} ({})",
206 repo.display(),
207 &base_commit[..base_commit.len().min(8)]
208 );
209 }
210 let roles = config.resolve_roles()?;
211 let max_parallel = config.graph.max_parallel.max(1);
212 let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
213 state.event("start", format!("run {} created", state.id));
214 state.save()?;
215 Ok(Self {
216 state,
217 roles,
218 sem: Arc::new(Semaphore::new(max_parallel)),
219 pause: Pause::new(),
220 })
221 }
222
223 pub async fn review(repo: &Path, branch: &str, config: Config) -> Result<Self> {
237 let repo = git::toplevel(repo).await?;
238 let missing = agent::missing_programs(&config.agents);
239 if !missing.is_empty() {
240 bail!(
241 "these agent programs are not on PATH: {}. Fix the roster in \
242 magi.toml or install them.",
243 missing.join(", ")
244 );
245 }
246 if !git::branch_exists(&repo, branch).await? {
247 bail!("no branch `{branch}` in {}", repo.display());
248 }
249 let base_branch = match config.merge.base.clone() {
250 Some(b) => b,
251 None => git::current_branch(&repo)
252 .await?
253 .context("HEAD is detached; set [merge] base in magi.toml")?,
254 };
255 if base_branch == branch {
256 bail!("`{branch}` is the base branch; there is nothing to review against");
257 }
258 let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
259
260 let roles = config.resolve_roles()?;
261 let max_parallel = config.graph.max_parallel.max(1);
262 let log = git::log_oneline(&repo, &base_commit, branch)
265 .await
266 .unwrap_or_default();
267 let instruction = format!(
268 "Review the work already on branch `{branch}`. There is no task \
269 statement: what the change claims to do is whatever its commits \
270 say.\n\n{}",
271 if log.trim().is_empty() {
272 "(no commit messages)"
273 } else {
274 log.trim()
275 }
276 );
277 let mut state = RunState::new(
278 repo.clone(),
279 base_branch,
280 base_commit.clone(),
281 instruction,
282 config,
283 );
284
285 let worktree = state.worktree_root().join("under-review");
288 if let Some(parent) = worktree.parent() {
289 tokio::fs::create_dir_all(parent).await.ok();
290 }
291 let path = worktree.to_string_lossy().to_string();
292 git::git(&repo, &["worktree", "add", &path, branch])
293 .await
294 .with_context(|| {
295 format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
296 })?;
297
298 let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
299 .await
300 .unwrap_or(0);
301 if commits == 0 {
302 bail!("`{branch}` has no commits beyond {}", short(&base_commit));
303 }
304 let files = git::changed_files(&worktree, &base_commit, "HEAD")
305 .await
306 .map(|f| f.len())
307 .unwrap_or(0);
308 let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
309 .await
310 .unwrap_or_default();
311
312 state.candidates.push(Candidate {
313 index: 0,
314 label: 'A',
315 agent: "(existing branch)".to_owned(),
318 branch: branch.to_owned(),
319 worktree,
320 summary: String::new(),
321 stat,
322 files,
323 commits,
324 empty: false,
325 failed: None,
326 duration_ms: 0,
327 folded: false,
328 });
329 state.tally = Some(Tally {
330 first_choice: BTreeMap::from([('A', 0)]),
331 borda: BTreeMap::new(),
332 winner: 'A',
333 rankings: 0,
334 unanimous_initial: false,
335 deliberated: false,
336 changed_votes: 0,
337 unanimous_final: false,
338 tie_break: Some("review-only run: nothing competed".to_owned()),
339 judges: 0,
343 present: 0,
344 quorum: 0,
345 met_quorum: true,
346 });
347 state.status = RunStatus::Reviewing;
348 state.event(
349 "start",
350 format!(
351 "review-only run {} on `{branch}` ({files} files, {commits} commits)",
352 state.id
353 ),
354 );
355 state.save()?;
356 Ok(Self {
357 state,
358 roles,
359 sem: Arc::new(Semaphore::new(max_parallel)),
360 pause: Pause::new(),
361 })
362 }
363
364 pub fn resume(id: &str) -> Result<Self> {
366 let state = RunState::load(id)?;
367 let roles = state.config.resolve_roles()?;
368 let max_parallel = state.config.graph.max_parallel.max(1);
369 Ok(Self {
370 state,
371 roles,
372 sem: Arc::new(Semaphore::new(max_parallel)),
373 pause: Pause::new(),
374 })
375 }
376
377 pub async fn execute(&mut self) -> Result<()> {
379 self.state.parked = false;
384 if self.state.status == RunStatus::Stalled {
397 if self.recover_stall().await? {
398 self.finish_after_tally().await?;
399 } else {
400 self.state.save()?;
402 }
403 return Ok(());
404 }
405 self.prep().await?;
406 if self.park_here()? {
407 return Ok(());
408 }
409 self.implement().await?;
410 if self.park_here()? {
411 return Ok(());
412 }
413 self.judge().await?;
414 if self.park_here()? {
415 return Ok(());
416 }
417 self.deliberate().await?;
418 if self.park_here()? {
419 return Ok(());
420 }
421 self.vote().await?;
422 if self.park_here()? {
423 return Ok(());
424 }
425 self.tally()?;
426 if self.state.status == RunStatus::Stalled {
431 self.state.save()?;
435 return Ok(());
436 }
437 self.finish_after_tally().await?;
438 Ok(())
439 }
440
441 fn park_here(&mut self) -> Result<bool> {
448 if !self.pause.parked() {
449 return Ok(false);
450 }
451 self.state.event(
452 "park",
453 format!(
454 "parked after `{}` — resume to carry on from here",
455 self.state.status.as_str()
456 ),
457 );
458 self.state.parked = true;
459 self.state.save()?;
460 Ok(true)
461 }
462
463 pub fn on_pause(&mut self, pause: Pause) {
465 self.pause = pause;
466 }
467
468 async fn finish_after_tally(&mut self) -> Result<()> {
471 self.fold_losers().await?;
472 self.review_loop().await?;
473 self.gate().await?;
474 self.merge().await?;
475 self.state.save()?;
476 Ok(())
477 }
478
479 async fn prep(&mut self) -> Result<()> {
482 if !self.state.candidates.is_empty() {
483 return Ok(());
484 }
485 self.state.status = RunStatus::Prep;
486 let repo = self.state.repo.clone();
487 let base = self.state.base_commit.clone();
488 let root = self.state.worktree_root();
489 let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
490
491 let hooks_dir = self.state.dir().join("hooks");
494 if self.state.config.blind.commit_msg_hook {
495 std::fs::create_dir_all(&hooks_dir)
496 .with_context(|| format!("create {}", hooks_dir.display()))?;
497 let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
498 let path = hooks_dir.join("commit-msg");
499 std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
500 make_executable(&path)?;
501 if git::enable_worktree_config(&repo).await? {
502 self.state.enabled_worktree_config = true;
503 }
504 }
505
506 for (index, (spec, label)) in self
507 .roles
508 .implementers
509 .clone()
510 .into_iter()
511 .zip(labels)
512 .enumerate()
513 {
514 let branch = self.state.branch_for(label);
515 let worktree = root.join(format!("cand-{label}"));
516 git::worktree_add_branch(&repo, &worktree, &branch, &base).await?;
517 if self.state.config.blind.commit_msg_hook {
518 git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
519 }
520 git::local_exclude(&worktree, "/.magi/").await?;
521 self.state.candidates.push(Candidate {
522 index,
523 label,
524 agent: spec.id.clone(),
525 branch,
526 worktree,
527 summary: String::new(),
528 stat: String::new(),
529 files: 0,
530 commits: 0,
531 empty: false,
532 failed: None,
533 duration_ms: 0,
534 folded: false,
535 });
536 }
537
538 for j in 1..=self.roles.judges.len() {
539 let wt = root.join(format!("judge-{j}"));
540 if !wt.exists() {
541 git::worktree_add_detached(&repo, &wt, &base).await?;
542 }
543 }
544
545 let authors: Vec<&str> = self
550 .roles
551 .implementers
552 .iter()
553 .map(|a| a.id.as_str())
554 .collect();
555 let overlap: Vec<String> = self
556 .roles
557 .judges
558 .iter()
559 .enumerate()
560 .filter(|(_, j)| authors.contains(&j.id.as_str()))
561 .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
562 .collect();
563 if !overlap.is_empty() {
564 let note = format!(
565 "{} also authored a candidate; blind, but the panel is less \
566 independent than {} distinct agents would be",
567 overlap.join(", "),
568 self.roles.judges.len()
569 );
570 self.state.event("prep", note);
571 }
572
573 self.state.event(
574 "prep",
575 format!(
576 "{} candidates, {} judges, base {} ({})",
577 self.state.candidates.len(),
578 self.roles.judges.len(),
579 &self.state.base_commit[..7.min(self.state.base_commit.len())],
580 self.state.base_branch
581 ),
582 );
583 self.state.status = RunStatus::Implementing;
584 self.state.save()?;
585 Ok(())
586 }
587
588 async fn implement(&mut self) -> Result<()> {
591 let run_id = self.state.id.clone();
596 let prompts = self.state.config.prompts.clone();
597 let todo: Vec<usize> = self
598 .state
599 .candidates
600 .iter()
601 .enumerate()
602 .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
603 .map(|(i, _)| i)
604 .collect();
605 if todo.is_empty() {
606 return self.after_implement();
607 }
608 self.state.status = RunStatus::Implementing;
609
610 let language = self.state.config.graph.language.clone();
611 let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
612 let sessions = self.state.config.graph.sessions;
613 let artifacts = agent::artifacts_dir(&self.state.dir());
614
615 let mut jobs = Vec::new();
616 for &i in &todo {
617 let (index, label, worktree) = {
618 let c = &self.state.candidates[i];
619 (c.index, c.label, c.worktree.clone())
620 };
621 let spec = self.roles.implementers[index].clone();
622 let seat_key = format!("impl-{label}");
623 let seat = self.seat(&seat_key, &spec.id);
624 let instruction = self.state.instruction.clone();
625 jobs.push(SeatJob {
626 spec,
627 seat,
628 prompt: prompt::implement(&instruction, &worktree.to_string_lossy(), &language),
629 cwd: worktree,
630 timeout,
631 allow_write: true,
632 sessions,
633 artifacts: artifacts.clone(),
634 stem: format!("impl-{label}"),
635 });
636 }
637
638 self.state.event(
639 "implement",
640 format!("{} candidates in parallel", jobs.len()),
641 );
642 let sent = jobs.clone();
645 let mut results = wave(
646 jobs,
647 Arc::clone(&self.sem),
648 &run_id,
649 "implement",
650 &prompts,
651 self.state.config.cache_dir().as_deref(),
652 )
653 .await;
654 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
655 .await;
656
657 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
658 let seat_key = seat.key.clone();
659 self.state.seats.insert(seat.key.clone(), seat);
660 let label = self.state.candidates[i].label;
661 let worktree = self.state.candidates[i].worktree.clone();
662 let base = self.state.base_commit.clone();
663
664 let (summary, duration, failed) = match out {
665 AgentOutcome::Ok(o) => {
666 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
667 let failed = (!o.usable()).then(|| {
668 if o.timed_out {
669 "agent timed out".to_owned()
670 } else {
671 format!("agent exited with {:?}", o.exit_code)
672 }
673 });
674 (text, o.duration_ms, failed)
675 }
676 AgentOutcome::Dropped(o) => {
682 let why = o
683 .dropped
684 .as_ref()
685 .map(|d| d.why.as_str())
686 .unwrap_or("the CLI ended the stream without delivering its answer");
687 (
688 String::new(),
689 o.duration_ms,
690 Some(format!("the CLI dropped the stream ({why})")),
691 )
692 }
693 AgentOutcome::Quota(o) => {
694 self.state.quota.push(QuotaLoss {
695 seat: seat_key,
696 node: "implement".to_owned(),
697 at: Timestamp::now(),
698 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
699 });
700 (
701 String::new(),
702 o.duration_ms,
703 Some("rate limited (quota); produced no change".to_owned()),
704 )
705 }
706 AgentOutcome::Failed(e) => (String::new(), 0, Some(e)),
707 };
708
709 let rescued = git::commit_all(
712 &worktree,
713 &format!("magi: candidate {label} (uncommitted work)"),
714 )
715 .await
716 .unwrap_or(false);
717 let commits = git::commits_ahead(&worktree, &base, "HEAD")
718 .await
719 .unwrap_or(0);
720 let patch = git::diff(&worktree, &base, "HEAD")
721 .await
722 .unwrap_or_default();
723 let stat = git::diff_stat(&worktree, &base, "HEAD")
724 .await
725 .unwrap_or_default();
726 let files = git::changed_files(&worktree, &base, "HEAD")
727 .await
728 .map(|f| f.len())
729 .unwrap_or(0);
730 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
731
732 let c = &mut self.state.candidates[i];
733 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
734 c.stat = stat;
735 c.files = files;
736 c.commits = commits;
737 c.duration_ms = duration;
738 c.empty = commits == 0 || patch.trim().is_empty();
739 c.failed = match failed {
742 Some(_) if c.empty => failed,
743 _ => None,
744 };
745 let note = match (&c.failed, c.empty, rescued) {
746 (Some(e), _, _) => format!("candidate {label}: {e}"),
747 (None, true, _) => format!("candidate {label}: no change produced"),
748 (None, false, true) => {
749 format!(
750 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
751 )
752 }
753 (None, false, false) => {
754 format!("candidate {label}: {files} files, {commits} commits")
755 }
756 };
757 self.state.event("implement", note);
758 self.state.save()?;
759 }
760
761 self.after_implement()
762 }
763
764 async fn resume_undelivered(
792 &mut self,
793 results: &mut [(usize, SeatState, AgentOutcome)],
794 sent: &[SeatJob],
795 prompts: &Prompts,
796 run_id: &str,
797 ) {
798 for (wi, seat, out) in results.iter_mut() {
799 let Some(dropped) = (match &*out {
800 AgentOutcome::Dropped(o) => o.dropped.clone(),
801 _ => None,
802 }) else {
803 continue;
804 };
805 let Some(job) = sent.get(*wi) else { continue };
806 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
808 self.state.event(
809 "implement",
810 format!(
811 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
812 work is in the tree",
813 seat.key, dropped.output_tokens, dropped.why
814 ),
815 );
816 continue;
817 }
818 if !has_context(&job.spec, seat, job.sessions) {
826 self.state.event(
827 "implement",
828 format!(
829 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
830 is no session left to resume",
831 seat.key, dropped.output_tokens, dropped.why
832 ),
833 );
834 continue;
835 }
836 self.state.event(
837 "implement",
838 format!(
839 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
840 conversation",
841 seat.key, dropped.output_tokens, dropped.why
842 ),
843 );
844 let mut retry = job.clone();
845 retry.seat = seat.clone();
846 retry.prompt = prompt::resume_after_drop(&dropped.why);
847 retry.timeout = retry_budget(job.timeout, true);
848 retry.stem = format!("{}-resume", job.stem);
849 let (resumed_seat, resumed) = run_one(
850 retry,
851 Arc::clone(&self.sem),
852 run_id,
853 "implement",
854 prompts,
855 self.state.config.cache_dir().as_deref(),
856 )
857 .await;
858 *seat = resumed_seat;
859 *out = resumed;
860 }
861 }
862
863 fn after_implement(&mut self) -> Result<()> {
864 if self.state.leaks.is_empty() {
866 let cfg = self.state.config.blind.clone();
867 let mut leaks = Vec::new();
868 for c in &self.state.candidates {
869 let Some(patch) =
870 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
871 else {
872 continue;
873 };
874 leaks.extend(blind::scan(
875 &format!("candidate {} patch", c.label),
876 &patch,
877 &cfg.vendor_tokens,
878 ));
879 }
880 if !leaks.is_empty() {
881 let summary = leaks
882 .iter()
883 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
884 .collect::<Vec<_>>()
885 .join(", ");
886 match cfg.on_leak {
887 LeakPolicy::Fail => {
888 self.state.status = RunStatus::Failed;
889 self.state
890 .event("blind", format!("vendor text in a patch: {summary}"));
891 self.state.leaks = leaks;
892 self.state.save()?;
893 bail!(
894 "blind.on_leak = \"fail\" and vendor text reached a \
895 judged patch: {summary}"
896 );
897 }
898 LeakPolicy::Redact => self.state.event(
899 "blind",
900 format!("redacting vendor text for judging: {summary}"),
901 ),
902 LeakPolicy::Warn => self.state.event(
903 "blind",
904 format!("vendor text present in a judged patch (shown as-is): {summary}"),
905 ),
906 }
907 self.state.leaks = leaks;
908 }
909 }
910
911 if self.state.viable().is_empty() {
912 self.state.status = RunStatus::Failed;
913 self.state.save()?;
914 bail!("no candidate produced a change; nothing to judge");
915 }
916 self.state.status = RunStatus::Judging;
917 self.state.save()?;
918 Ok(())
919 }
920
921 async fn judge(&mut self) -> Result<()> {
924 let run_id = self.state.id.clone();
929 let prompts = self.state.config.prompts.clone();
930 if !self.state.judgements.is_empty() {
931 return Ok(());
932 }
933 self.state.status = RunStatus::Judging;
934 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
935 if viable.len() == 1 {
936 self.state.event(
937 "judge",
938 format!(
939 "only candidate {} produced a change; judging skipped",
940 viable[0].label
941 ),
942 );
943 self.state.save()?;
944 return Ok(());
945 }
946
947 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
948 let language = self.state.config.graph.language.clone();
949 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
950 let sessions = self.state.config.graph.sessions;
951 let artifacts = agent::artifacts_dir(&self.state.dir());
952 let root = self.state.worktree_root();
953 let base_short = short(&self.state.base_commit);
954
955 let mut jobs = Vec::new();
956 let mut orders = Vec::new();
957 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
958 let order = blind::presentation_order(viable.len(), j, self.state.seed);
959 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
960 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
961 let seat_key = format!("judge-{}", j + 1);
962 let seat = self.seat(&seat_key, &spec.id);
963 jobs.push(SeatJob {
964 prompt: prompt::judge(
965 &self.state.instruction,
966 &views,
967 self.roles.judges.len(),
968 &base_short,
969 &language,
970 ),
971 spec,
972 seat,
973 cwd: root.join(format!("judge-{}", j + 1)),
974 timeout,
975 allow_write: false,
976 sessions,
977 artifacts: artifacts.clone(),
978 stem: format!("judge-{}", j + 1),
979 });
980 }
981
982 self.state.event(
983 "judge",
984 format!(
985 "{} judges ranking {} candidates blind",
986 jobs.len(),
987 viable.len()
988 ),
989 );
990 let labels_for_check = labels.clone();
991 let mut quota_losses = Vec::new();
992 let results = ask_json_wave::<Ranking>(
993 jobs,
994 Arc::clone(&self.sem),
995 self.state.config.graph.retries,
996 &run_id,
997 "judge",
998 &prompts,
999 self.state.config.cache_dir().as_deref(),
1000 &mut quota_losses,
1001 &move |r: &Ranking| r.validate(&labels_for_check),
1002 )
1003 .await;
1004 self.state.quota.extend(quota_losses);
1005
1006 for (j, (seat, res)) in results.into_iter().enumerate() {
1007 let agent_id = seat.agent.clone();
1008 self.state.seats.insert(seat.key.clone(), seat);
1009 let mut record = Judgement {
1010 judge: j + 1,
1011 seat: format!("judge-{}", j + 1),
1012 agent: agent_id,
1013 ranking: Vec::new(),
1014 reasons: BTreeMap::new(),
1015 confidence: None,
1016 order: orders[j].clone(),
1017 failed: None,
1018 duration_ms: 0,
1019 };
1020 match res {
1021 Ok((ranking, out)) => {
1022 record.ranking = ranking.normalized();
1023 record.reasons = ranking.reasons;
1024 record.confidence = ranking.confidence;
1025 record.duration_ms = out.duration_ms;
1026 self.state.event(
1027 "judge",
1028 format!(
1029 "judge {} ranked {}",
1030 j + 1,
1031 record.ranking.iter().collect::<String>()
1032 ),
1033 );
1034 }
1035 Err(e) => {
1036 record.failed = Some(e.to_string());
1037 self.state
1038 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
1039 }
1040 }
1041 self.state.judgements.push(record);
1042 self.state.save()?;
1043 }
1044 Ok(())
1045 }
1046
1047 async fn deliberate(&mut self) -> Result<()> {
1050 let run_id = self.state.id.clone();
1055 let prompts = self.state.config.prompts.clone();
1056 if !self.state.deliberation.is_empty() {
1057 return Ok(());
1058 }
1059 let tops: Vec<char> = self
1060 .state
1061 .judgements
1062 .iter()
1063 .filter_map(|j| j.ranking.first().copied())
1064 .collect();
1065 let rounds = self.state.config.graph.deliberate_rounds;
1066 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
1067 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
1068 self.state.event(
1069 "deliberate",
1070 format!("judges agreed on {} outright; no deliberation", tops[0]),
1071 );
1072 }
1073 self.state.status = RunStatus::Voting;
1074 self.state.save()?;
1075 return Ok(());
1076 }
1077
1078 self.state.status = RunStatus::Deliberating;
1079 self.state.event(
1080 "deliberate",
1081 format!(
1082 "split: first choices were {} — opening {rounds} round(s)",
1083 tops.iter().collect::<String>()
1084 ),
1085 );
1086
1087 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1088 let language = self.state.config.graph.language.clone();
1089 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1090 let sessions = self.state.config.graph.sessions;
1091 let artifacts = agent::artifacts_dir(&self.state.dir());
1092 let root = self.state.worktree_root();
1093 let base_short = short(&self.state.base_commit);
1094
1095 for round in 1..=rounds {
1099 let mut turns: Vec<DeliberationTurn> = Vec::new();
1100 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1101 if self.state.judgements[j].failed.is_some() {
1102 continue;
1103 }
1104 let seat_key = format!("judge-{}", j + 1);
1105 let mut seat = self.seat(&seat_key, &spec.id);
1106 let transcript = self.transcript(&turns, j);
1107 let context = if has_context(&spec, &seat, sessions) {
1108 None
1109 } else {
1110 Some(self.candidate_block(&viable, &base_short))
1111 };
1112 let text = prompt::deliberate(
1113 &self.state.instruction,
1114 context.as_deref(),
1115 &transcript,
1116 round,
1117 rounds,
1118 &language,
1119 );
1120 let job = SeatJob {
1121 spec,
1122 seat: seat.clone(),
1123 prompt: text,
1124 cwd: root.join(format!("judge-{}", j + 1)),
1125 timeout,
1126 allow_write: false,
1127 sessions,
1128 artifacts: artifacts.clone(),
1129 stem: format!("delib-{round}-judge-{}", j + 1),
1130 };
1131 let (updated, out) = run_one(
1132 job,
1133 Arc::clone(&self.sem),
1134 &run_id,
1135 "deliberate",
1136 &prompts,
1137 self.state.config.cache_dir().as_deref(),
1138 )
1139 .await;
1140 seat = updated;
1141 let agent_id = seat.agent.clone();
1142 let seat_key = seat.key.clone();
1143 self.state.seats.insert(seat.key.clone(), seat);
1144 let body = match out {
1145 AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
1146 AgentOutcome::Dropped(o) => {
1150 let why =
1151 o.dropped.as_ref().map(|d| d.why.as_str()).unwrap_or(
1152 "the CLI ended the stream without delivering its answer",
1153 );
1154 self.state.event(
1155 "deliberate",
1156 format!(
1157 "judge {} skipped: the CLI dropped the stream ({why})",
1158 j + 1
1159 ),
1160 );
1161 continue;
1162 }
1163 AgentOutcome::Quota(o) => {
1164 self.state.quota.push(QuotaLoss {
1165 seat: seat_key,
1166 node: "deliberate".to_owned(),
1167 at: Timestamp::now(),
1168 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1169 });
1170 self.state.event(
1171 "deliberate",
1172 format!("judge {} skipped: rate limited (quota)", j + 1),
1173 );
1174 continue;
1175 }
1176 AgentOutcome::Failed(e) => {
1177 self.state
1178 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
1179 continue;
1180 }
1181 };
1182 let tentative = verdict::extract_json::<Position>(&body)
1183 .ok()
1184 .and_then(|p| p.tentative)
1185 .and_then(|s| s.trim().chars().next())
1186 .map(|c| c.to_ascii_uppercase());
1187 self.state.event(
1188 "deliberate",
1189 format!(
1190 "round {round}: judge {} now favours {}",
1191 j + 1,
1192 tentative.map_or("—".to_owned(), |c| c.to_string())
1193 ),
1194 );
1195 turns.push(DeliberationTurn {
1196 judge: j + 1,
1197 agent: agent_id,
1198 body: blind::sanitize_prose(&body, &self.state.config.blind),
1199 tentative,
1200 });
1201 }
1202 self.state
1203 .deliberation
1204 .push(DeliberationRound { round, turns });
1205 self.state.save()?;
1206 }
1207
1208 self.state.status = RunStatus::Voting;
1209 self.state.save()?;
1210 Ok(())
1211 }
1212
1213 async fn vote(&mut self) -> Result<()> {
1216 let run_id = self.state.id.clone();
1221 let prompts = self.state.config.prompts.clone();
1222 if !self.state.votes.is_empty() {
1223 return Ok(());
1224 }
1225 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1226 if viable.len() == 1 {
1227 return Ok(());
1228 }
1229 self.state.status = RunStatus::Voting;
1230
1231 let language = self.state.config.graph.language.clone();
1232 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1233 let sessions = self.state.config.graph.sessions;
1234 let artifacts = agent::artifacts_dir(&self.state.dir());
1235 let root = self.state.worktree_root();
1236 let base_short = short(&self.state.base_commit);
1237 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1238
1239 let mut jobs = Vec::new();
1240 let mut seats_at = Vec::new();
1241 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1242 if self
1243 .state
1244 .judgements
1245 .get(j)
1246 .is_some_and(|r| r.failed.is_some())
1247 {
1248 continue;
1249 }
1250 let seat_key = format!("judge-{}", j + 1);
1251 let seat = self.seat(&seat_key, &spec.id);
1252 let mut text = prompt::final_vote(&viable, &language);
1253 if !has_context(&spec, &seat, sessions) {
1254 text = format!(
1255 "{}\n\n# Candidates\n\n{}",
1256 text,
1257 self.candidate_block(&candidates, &base_short)
1258 );
1259 }
1260 jobs.push(SeatJob {
1261 spec,
1262 seat,
1263 prompt: text,
1264 cwd: root.join(format!("judge-{}", j + 1)),
1265 timeout,
1266 allow_write: false,
1267 sessions,
1268 artifacts: artifacts.clone(),
1269 stem: format!("vote-judge-{}", j + 1),
1270 });
1271 seats_at.push(j);
1272 }
1273
1274 self.state.event(
1275 "vote",
1276 format!(
1277 "collecting {} final votes one by one, privately",
1278 jobs.len()
1279 ),
1280 );
1281 let allowed = viable.clone();
1282 let mut quota_losses = Vec::new();
1283 let results = ask_json_wave::<FinalVote>(
1284 jobs,
1285 Arc::clone(&self.sem),
1286 self.state.config.graph.retries,
1287 &run_id,
1288 "vote",
1289 &prompts,
1290 self.state.config.cache_dir().as_deref(),
1291 &mut quota_losses,
1292 &move |v: &FinalVote| match v.label() {
1293 Some(c) if allowed.contains(&c) => Ok(()),
1294 other => bail!("vote {other:?} is not one of {allowed:?}"),
1295 },
1296 )
1297 .await;
1298 self.state.quota.extend(quota_losses);
1299
1300 for (&j, (seat, res)) in seats_at.iter().zip(results) {
1301 let agent_id = seat.agent.clone();
1302 self.state.seats.insert(seat.key.clone(), seat);
1303 let initial = self
1304 .state
1305 .judgements
1306 .get(j)
1307 .and_then(|r| r.ranking.first().copied());
1308 let mut record = VoteRecord {
1309 judge: j + 1,
1310 agent: agent_id,
1311 vote: None,
1312 reason: String::new(),
1313 changed: false,
1314 };
1315 match res {
1316 Ok((v, _)) => {
1317 record.vote = v.label();
1318 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1319 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
1320 self.state.event(
1321 "vote",
1322 format!(
1323 "judge {} voted {}{}",
1324 j + 1,
1325 record.vote.unwrap_or('?'),
1326 if record.changed { " (changed)" } else { "" }
1327 ),
1328 );
1329 }
1330 Err(e) => {
1331 self.state
1332 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
1333 }
1334 }
1335 self.state.votes.push(record);
1336 self.state.save()?;
1337 }
1338 Ok(())
1339 }
1340
1341 fn tally(&mut self) -> Result<()> {
1344 if self.state.tally.is_some() {
1345 return Ok(());
1346 }
1347 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1348 let tops: Vec<char> = self
1349 .state
1350 .judgements
1351 .iter()
1352 .filter_map(|j| j.ranking.first().copied())
1353 .collect();
1354 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
1355
1356 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1359 let mut cast: Vec<char> = Vec::new();
1360 for (i, j) in self.state.judgements.iter().enumerate() {
1361 let vote = self
1362 .state
1363 .votes
1364 .iter()
1365 .find(|v| v.judge == i + 1)
1366 .and_then(|v| v.vote)
1367 .or_else(|| j.ranking.first().copied());
1368 if let Some(v) = vote {
1369 *first_choice.entry(v).or_insert(0) += 1;
1370 cast.push(v);
1371 }
1372 }
1373
1374 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1375 for j in &self.state.judgements {
1376 let n = j.ranking.len();
1377 for (pos, label) in j.ranking.iter().enumerate() {
1378 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
1379 }
1380 }
1381
1382 let best = first_choice.values().copied().max().unwrap_or(0);
1383 let mut leaders: Vec<char> = first_choice
1384 .iter()
1385 .filter(|(_, v)| **v == best)
1386 .map(|(k, _)| *k)
1387 .collect();
1388 let mut tie_break = None;
1389 if leaders.len() > 1 {
1390 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
1391 let borda_leaders: Vec<char> = leaders
1392 .iter()
1393 .copied()
1394 .filter(|l| borda[l] == top_borda)
1395 .collect();
1396 tie_break = Some(if borda_leaders.len() == 1 {
1397 format!(
1398 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
1399 leaders.len()
1400 )
1401 } else {
1402 format!(
1403 "{} way tie on both first-choice votes and Borda points, broken by label order",
1404 leaders.len()
1405 )
1406 });
1407 leaders = borda_leaders;
1408 leaders.sort_unstable();
1409 }
1410 let winner = *leaders
1411 .first()
1412 .or(viable.first())
1413 .context("no candidate to declare a winner from")?;
1414
1415 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
1416 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
1417 let deliberated = !self.state.deliberation.is_empty();
1418
1419 let quota_seats: std::collections::BTreeSet<&str> =
1423 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1424 let mut present = 0usize;
1425 for (i, j) in self.state.judgements.iter().enumerate() {
1426 if quota_seats.contains(j.seat.as_str()) {
1427 continue;
1428 }
1429 let ranked = !j.ranking.is_empty() && j.failed.is_none();
1430 let voted = self
1431 .state
1432 .votes
1433 .iter()
1434 .any(|v| v.judge == i + 1 && v.vote.is_some());
1435 if ranked || voted {
1436 present += 1;
1437 }
1438 }
1439 let judges_total = self.roles.judges.len();
1443 let needs_quorum = viable.len() > 1;
1444 let quorum = if needs_quorum {
1445 judges_total / 2 + 1
1446 } else {
1447 0
1448 };
1449 let met_quorum = !needs_quorum || present >= quorum;
1450
1451 self.state.event(
1452 "tally",
1453 format!(
1454 "winner {winner} — votes {} | initial {} | {} changed | {present}/{judges_total} judges{}",
1455 first_choice
1456 .iter()
1457 .map(|(k, v)| format!("{k}:{v}"))
1458 .collect::<Vec<_>>()
1459 .join(" "),
1460 if unanimous_initial {
1461 "unanimous"
1462 } else {
1463 "split"
1464 },
1465 changed_votes,
1466 if met_quorum {
1467 String::new()
1468 } else {
1469 format!(" — below quorum ({quorum} required)")
1470 },
1471 ),
1472 );
1473 if !met_quorum {
1474 self.state.event(
1475 "stall",
1476 format!(
1477 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
1478 the run stops here, resumable"
1479 ),
1480 );
1481 }
1482 self.state.tally = Some(Tally {
1483 first_choice,
1484 borda,
1485 winner,
1486 rankings: tops.len(),
1487 unanimous_initial,
1488 deliberated,
1489 changed_votes,
1490 unanimous_final,
1491 tie_break,
1492 judges: judges_total,
1493 present,
1494 quorum,
1495 met_quorum,
1496 });
1497 self.state.status = if met_quorum {
1498 RunStatus::Reviewing
1499 } else {
1500 RunStatus::Stalled
1501 };
1502 self.state.save()?;
1503 Ok(())
1504 }
1505
1506 #[allow(clippy::too_many_lines)]
1527 async fn recover_stall(&mut self) -> Result<bool> {
1528 let run_id = self.state.id.clone();
1533 let prompts = self.state.config.prompts.clone();
1534 let quota_seats: BTreeSet<&str> =
1539 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1540 let absent: Vec<String> = self
1541 .state
1542 .judgements
1543 .iter()
1544 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
1545 .map(|j| j.seat.clone())
1546 .collect();
1547 if absent.is_empty() {
1548 return Ok(false);
1549 }
1550 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1551 if viable.len() <= 1 {
1552 return Ok(false);
1553 }
1554 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1555 let language = self.state.config.graph.language.clone();
1556 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1557 let sessions = self.state.config.graph.sessions;
1558 let artifacts = agent::artifacts_dir(&self.state.dir());
1559 let root = self.state.worktree_root();
1560 let base_short = short(&self.state.base_commit);
1561 let candidates: Vec<Candidate> = viable.clone();
1562
1563 let mut positions: Vec<usize> = absent
1565 .iter()
1566 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
1567 .collect();
1568 if positions.is_empty() {
1569 return Ok(false);
1570 }
1571 positions.sort_unstable();
1572 positions.dedup();
1573
1574 let mut judge_jobs = Vec::new();
1576 for &j in &positions {
1577 let order = blind::presentation_order(viable.len(), j, self.state.seed);
1578 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1579 let seat_key = format!("judge-{}", j + 1);
1580 let spec = self.roles.judges[j].clone();
1581 let seat = self.seat(&seat_key, &spec.id);
1582 judge_jobs.push(SeatJob {
1583 spec,
1584 seat,
1585 prompt: prompt::judge(
1586 &self.state.instruction,
1587 &views,
1588 self.roles.judges.len(),
1589 &base_short,
1590 &language,
1591 ),
1592 cwd: root.join(seat_key),
1593 timeout,
1594 allow_write: false,
1595 sessions,
1596 artifacts: artifacts.clone(),
1597 stem: format!("judge-{}-recover", j + 1),
1598 });
1599 }
1600
1601 let labels_for_check = labels.clone();
1602 let mut judge_losses = Vec::new();
1603 let results = ask_json_wave::<Ranking>(
1604 judge_jobs,
1605 Arc::clone(&self.sem),
1606 self.state.config.graph.retries,
1607 &run_id,
1608 "judge",
1609 &prompts,
1610 self.state.config.cache_dir().as_deref(),
1611 &mut judge_losses,
1612 &move |r: &Ranking| r.validate(&labels_for_check),
1613 )
1614 .await;
1615
1616 let mut recovered: BTreeSet<usize> = BTreeSet::new();
1618 for (&j, (seat, res)) in positions.iter().zip(results) {
1619 self.state.seats.insert(seat.key.clone(), seat);
1620 let record = &mut self.state.judgements[j];
1621 match res {
1622 Ok((ranking, out)) => {
1623 record.ranking = ranking.normalized();
1624 record.reasons = ranking.reasons;
1625 record.confidence = ranking.confidence;
1626 record.failed = None;
1627 record.duration_ms = out.duration_ms;
1628 recovered.insert(j);
1629 self.state.event(
1630 "recover",
1631 format!("judge {} ranked again after the limit", j + 1),
1632 );
1633 }
1634 Err(e) => {
1635 self.state
1636 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
1637 }
1638 }
1639 }
1640
1641 let mut vote_jobs = Vec::new();
1643 let mut vote_pos: Vec<usize> = Vec::new();
1644 for &j in &recovered {
1645 let seat_key = format!("judge-{}", j + 1);
1646 let spec = self.roles.judges[j].clone();
1647 let seat = self.seat(&seat_key, &spec.id);
1648 let mut text = prompt::final_vote(&labels, &language);
1649 if !has_context(&spec, &seat, sessions) {
1650 text = format!(
1651 "{}\n\n# Candidates\n\n{}",
1652 text,
1653 self.candidate_block(&candidates, &base_short)
1654 );
1655 }
1656 vote_jobs.push(SeatJob {
1657 spec,
1658 seat,
1659 prompt: text,
1660 cwd: root.join(seat_key),
1661 timeout,
1662 allow_write: false,
1663 sessions,
1664 artifacts: artifacts.clone(),
1665 stem: format!("vote-judge-{}-recover", j + 1),
1666 });
1667 vote_pos.push(j);
1668 }
1669 let allowed = labels.clone();
1670 let mut vote_losses = Vec::new();
1671 let votes = ask_json_wave::<FinalVote>(
1672 vote_jobs,
1673 Arc::clone(&self.sem),
1674 self.state.config.graph.retries,
1675 &run_id,
1676 "vote",
1677 &prompts,
1678 self.state.config.cache_dir().as_deref(),
1679 &mut vote_losses,
1680 &move |v: &FinalVote| match v.label() {
1681 Some(c) if allowed.contains(&c) => Ok(()),
1682 other => bail!("vote {other:?} is not one of {allowed:?}"),
1683 },
1684 )
1685 .await;
1686 for (&j, (seat, res)) in vote_pos.iter().zip(votes) {
1687 let agent_id = seat.agent.clone();
1688 self.state.seats.insert(seat.key.clone(), seat);
1689 match res {
1690 Ok((v, _)) => {
1691 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
1692 rec.vote = v.label();
1693 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1694 } else {
1695 self.state.votes.push(VoteRecord {
1696 judge: j + 1,
1697 agent: agent_id,
1698 vote: v.label(),
1699 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
1700 changed: false,
1701 });
1702 }
1703 self.state.event(
1704 "recover",
1705 format!("judge {} voted again after the limit", j + 1),
1706 );
1707 }
1708 Err(e) => {
1709 self.state
1710 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
1711 }
1712 }
1713 }
1714
1715 if !recovered.is_empty() {
1719 let recovered_keys: BTreeSet<String> = recovered
1720 .iter()
1721 .map(|&j| format!("judge-{}", j + 1))
1722 .collect();
1723 self.state
1724 .quota
1725 .retain(|q| !recovered_keys.contains(&q.seat));
1726 }
1727
1728 self.state.tally = None;
1730 self.tally()?;
1731 Ok(self
1732 .state
1733 .tally
1734 .as_ref()
1735 .map(|t| t.met_quorum)
1736 .unwrap_or(false))
1737 }
1738
1739 async fn fold_losers(&mut self) -> Result<()> {
1742 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
1743 return Ok(());
1744 };
1745 let repo = self.state.repo.clone();
1746 let mut folded = Vec::new();
1747 for i in 0..self.state.candidates.len() {
1748 let c = &self.state.candidates[i];
1749 if c.label == winner || c.folded {
1750 continue;
1751 }
1752 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
1753 git::worktree_remove(&repo, &wt).await.ok();
1754 git::branch_delete(&repo, &branch).await.ok();
1755 self.state.candidates[i].folded = true;
1756 folded.push(label.to_string());
1757 }
1758 let root = self.state.worktree_root();
1760 for j in 1..=self.roles.judges.len() {
1761 let wt = root.join(format!("judge-{j}"));
1762 if wt.exists() {
1763 git::worktree_remove(&repo, &wt).await.ok();
1764 }
1765 }
1766 if !folded.is_empty() {
1767 self.state
1768 .event("fold", format!("folded candidates {}", folded.join(", ")));
1769 self.state.save()?;
1770 }
1771 Ok(())
1772 }
1773
1774 async fn review_loop(&mut self) -> Result<()> {
1777 let run_id = self.state.id.clone();
1782 let prompts = self.state.config.prompts.clone();
1783 let Some(winner) = self.state.winner().cloned() else {
1784 return Ok(());
1785 };
1786 let max_rounds = self.state.config.graph.review_rounds;
1787 if max_rounds == 0 || self.state.reviews.iter().any(|r| r.clean) {
1788 self.state.status = RunStatus::Gating;
1789 self.state.save()?;
1790 return Ok(());
1791 }
1792 self.state.status = RunStatus::Reviewing;
1793
1794 let repo = self.state.repo.clone();
1795 let root = self.state.worktree_root();
1796 let language = self.state.config.graph.language.clone();
1797 let sessions = self.state.config.graph.sessions;
1798 let artifacts = agent::artifacts_dir(&self.state.dir());
1799 let base_short = short(&self.state.base_commit);
1800 let reviewers = self.roles.reviewers.clone();
1801 let base = self.state.base_commit.clone();
1802 let shell = self.state.config.shell();
1803
1804 let mut prev_e2e: Option<String> = None;
1805 for round in (self.state.reviews.len() + 1)..=max_rounds {
1806 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
1807 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
1808 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
1809
1810 let mut jobs = Vec::new();
1814 for (r, spec) in reviewers.iter().cloned().enumerate() {
1815 let wt = root.join(format!("review-{}", r + 1));
1816 if wt.exists() {
1817 git::reset_detached(&wt, &head).await?;
1818 } else {
1819 git::worktree_add_detached(&repo, &wt, &head).await?;
1820 }
1821 let seat_key = format!("review-{}", r + 1);
1822 let seat = self.seat(&seat_key, &spec.id);
1823 jobs.push(SeatJob {
1824 prompt: prompt::review(&prompt::ReviewCtx {
1825 instruction: &self.state.instruction,
1826 branch: &winner.branch,
1827 base_short: &base_short,
1828 stat: &stat,
1829 patch: &patch,
1830 e2e: prev_e2e.as_deref(),
1831 reviewers: reviewers.len(),
1832 round,
1833 rounds: max_rounds,
1834 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
1837 language: &language,
1838 }),
1839 spec,
1840 seat,
1841 cwd: wt,
1842 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
1843 allow_write: false,
1844 sessions,
1845 artifacts: artifacts.clone(),
1846 stem: format!("review-{round}-{}", r + 1),
1847 });
1848 }
1849
1850 self.state.event(
1851 "review",
1852 format!(
1853 "round {round}: {} reviewers on {}",
1854 jobs.len(),
1855 short(&head)
1856 ),
1857 );
1858 let mut quota_losses = Vec::new();
1859 let results = ask_json_wave::<Review>(
1860 jobs,
1861 Arc::clone(&self.sem),
1862 self.state.config.graph.retries,
1863 &run_id,
1864 "review",
1865 &prompts,
1866 self.state.config.cache_dir().as_deref(),
1867 &mut quota_losses,
1868 &|_: &Review| Ok(()),
1869 )
1870 .await;
1871 self.state.quota.extend(quota_losses);
1872
1873 let mut records = Vec::new();
1874 let mut all_findings = Vec::new();
1875 for (r, (seat, res)) in results.into_iter().enumerate() {
1876 let agent_id = seat.agent.clone();
1877 self.state.seats.insert(seat.key.clone(), seat);
1878 let mut record = ReviewRecord {
1879 reviewer: r + 1,
1880 agent: agent_id,
1881 summary: String::new(),
1882 findings: Vec::new(),
1883 failed: None,
1884 duration_ms: 0,
1885 };
1886 match res {
1887 Ok((review, out)) => {
1888 record.summary = review.summary;
1889 record.duration_ms = out.duration_ms;
1890 for (n, mut f) in review.findings.into_iter().enumerate() {
1891 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
1894 all_findings.push(f.clone());
1895 record.findings.push(f);
1896 }
1897 self.state.event(
1898 "review",
1899 format!(
1900 "round {round}: reviewer {} raised {} finding(s)",
1901 r + 1,
1902 record.findings.len()
1903 ),
1904 );
1905 }
1906 Err(e) => {
1907 record.failed = Some(e.to_string());
1908 self.state.event(
1909 "review",
1910 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
1911 );
1912 }
1913 }
1914 records.push(record);
1915 }
1916
1917 let e2e = run_commands(
1918 &shell,
1919 &self.state.config.verify.e2e,
1920 &winner.worktree,
1921 Duration::from_secs(self.state.config.graph.timeout_review),
1922 )
1923 .await;
1924 for o in &e2e {
1925 self.state.event(
1926 "verify",
1927 format!(
1928 "round {round}: `{}` -> {}",
1929 o.command,
1930 if o.ok() {
1931 "pass".to_owned()
1932 } else {
1933 format!("FAIL ({:?})", o.code)
1934 }
1935 ),
1936 );
1937 }
1938 let e2e_failures: String = e2e
1939 .iter()
1940 .filter(|o| !o.ok())
1941 .map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
1942 .collect();
1943
1944 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
1945 let clean = blocking == 0 && e2e.iter().all(CommandOutcome::ok);
1946
1947 let mut round_record = ReviewRound {
1948 round,
1949 head: head.clone(),
1950 reviews: records,
1951 e2e,
1952 fix: None,
1953 blocking,
1954 clean,
1955 };
1956
1957 if clean {
1958 self.state.event(
1959 "review",
1960 format!("round {round}: clean — no blocking findings, verification green"),
1961 );
1962 self.state.reviews.push(round_record);
1963 self.state.status = RunStatus::Gating;
1964 self.state.save()?;
1965 return Ok(());
1966 }
1967
1968 if round == max_rounds {
1969 self.state.reviews.push(round_record);
1970 self.state.status = RunStatus::Blocked;
1971 self.state.event(
1972 "review",
1973 format!("{blocking} blocking finding(s) still open after {max_rounds} rounds"),
1974 );
1975 self.state.save()?;
1976 return Ok(());
1977 }
1978
1979 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
1982 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1983 _ => (
1984 self.state
1985 .config
1986 .agent(&winner.agent)
1987 .cloned()
1988 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
1989 format!("impl-{}", winner.label),
1990 ),
1991 };
1992 let seat = self.seat(&fix_seat_key, &fix_spec.id);
1993 let blocking_findings: Vec<_> = all_findings
1994 .iter()
1995 .filter(|f| f.severity.blocks())
1996 .cloned()
1997 .collect();
1998 let job = SeatJob {
1999 prompt: prompt::fix(
2000 &self.state.instruction,
2001 &blocking_findings,
2002 (!e2e_failures.is_empty()).then_some(e2e_failures.as_str()),
2003 round,
2004 max_rounds,
2005 &language,
2006 ),
2007 spec: fix_spec.clone(),
2008 seat,
2009 cwd: winner.worktree.clone(),
2010 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
2011 allow_write: true,
2012 sessions,
2013 artifacts: artifacts.clone(),
2014 stem: format!("fix-{round}"),
2015 };
2016 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2017 let (seat, out) = run_one(
2018 job,
2019 Arc::clone(&self.sem),
2020 &run_id,
2021 "fix",
2022 &prompts,
2023 self.state.config.cache_dir().as_deref(),
2024 )
2025 .await;
2026 let agent_id = seat.agent.clone();
2027 let seat_key = seat.key.clone();
2028 self.state.seats.insert(seat.key.clone(), seat);
2029
2030 let mut fix = FixRecord {
2031 agent: agent_id,
2032 addressed: Vec::new(),
2033 rejected: Vec::new(),
2034 notes: String::new(),
2035 committed: false,
2036 failed: None,
2037 duration_ms: 0,
2038 };
2039 match out {
2040 AgentOutcome::Ok(o) => {
2041 fix.duration_ms = o.duration_ms;
2042 match verdict::extract_json::<FixReport>(&o.text) {
2043 Ok(report) => {
2044 fix.addressed = report.addressed;
2045 fix.rejected = report.rejected;
2046 fix.notes =
2047 blind::sanitize_prose(&report.notes, &self.state.config.blind);
2048 }
2049 Err(e) => fix.failed = Some(format!("unparsable fix report: {e}")),
2050 }
2051 }
2052 AgentOutcome::Dropped(o) => {
2054 fix.duration_ms = o.duration_ms;
2055 let why = o
2056 .dropped
2057 .as_ref()
2058 .map(|d| d.why.as_str())
2059 .unwrap_or("the CLI ended the stream without delivering its answer");
2060 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
2061 }
2062 AgentOutcome::Quota(o) => {
2063 self.state.quota.push(QuotaLoss {
2064 seat: seat_key,
2065 node: "fix".to_owned(),
2066 at: Timestamp::now(),
2067 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2068 });
2069 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
2070 }
2071 AgentOutcome::Failed(e) => fix.failed = Some(e),
2072 }
2073 git::commit_all(
2074 &winner.worktree,
2075 &format!("magi: review round {round} fixes (uncommitted work)"),
2076 )
2077 .await
2078 .ok();
2079 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2080 fix.committed = after != before;
2081 self.state.event(
2082 "fix",
2083 format!(
2084 "round {round}: {} addressed, {} rejected, {}",
2085 fix.addressed.len(),
2086 fix.rejected.len(),
2087 if fix.committed {
2088 "committed"
2089 } else {
2090 "NO new commit"
2091 }
2092 ),
2093 );
2094 let stalled = !fix.committed;
2095 round_record.fix = Some(fix);
2096 self.state.reviews.push(round_record);
2097 self.state.save()?;
2098
2099 prev_e2e = (!e2e_failures.is_empty()).then_some(e2e_failures);
2100
2101 if stalled {
2102 self.state.status = RunStatus::Blocked;
2103 self.state.event(
2104 "review",
2105 "the fixer produced no commit; stopping instead of looping on an unchanged tree"
2106 .to_owned(),
2107 );
2108 self.state.save()?;
2109 return Ok(());
2110 }
2111 }
2112 Ok(())
2113 }
2114
2115 async fn gate(&mut self) -> Result<()> {
2118 if self.state.status == RunStatus::Blocked || self.state.status == RunStatus::Failed {
2119 return Ok(());
2120 }
2121 if !self.state.gate.is_empty() {
2122 return Ok(());
2123 }
2124 let Some(winner) = self.state.winner().cloned() else {
2125 return Ok(());
2126 };
2127 self.state.status = RunStatus::Gating;
2128 let shell = self.state.config.shell();
2129 let outcomes = run_commands(
2130 &shell,
2131 &self.state.config.verify.gate,
2132 &winner.worktree,
2133 Duration::from_secs(self.state.config.graph.timeout_review),
2134 )
2135 .await;
2136 for o in &outcomes {
2137 self.state.event(
2138 "gate",
2139 format!(
2140 "`{}` -> {}",
2141 o.command,
2142 if o.ok() {
2143 "pass".to_owned()
2144 } else {
2145 format!("FAIL ({:?})", o.code)
2146 }
2147 ),
2148 );
2149 }
2150 let passed = outcomes.iter().all(CommandOutcome::ok);
2151 self.state.gate = outcomes;
2152 if !passed {
2153 self.state.status = RunStatus::Blocked;
2154 self.state.event("gate", "gate failed; not merging");
2155 }
2156 self.state.save()?;
2157 Ok(())
2158 }
2159
2160 async fn merge(&mut self) -> Result<()> {
2163 if self.state.status.done() && self.state.status != RunStatus::Ready {
2164 return Ok(());
2165 }
2166 let Some(winner) = self.state.winner().cloned() else {
2167 return Ok(());
2168 };
2169 let repo = self.state.repo.clone();
2170 let base = self.state.base_branch.clone();
2171 let mode = self.state.config.merge.mode;
2172 let message = format!(
2173 "Merge magi run {} (candidate {})\n\n{}",
2174 self.state.id, winner.label, self.state.instruction
2175 );
2176
2177 let outcome = match mode {
2178 MergeMode::None => MergeOutcome {
2179 mode,
2180 ok: true,
2181 detail: format!("git -C {} merge --no-ff {}", repo.display(), winner.branch),
2182 },
2183 MergeMode::Local => {
2184 let on = git::current_branch(&repo).await?;
2185 if on.as_deref() != Some(base.as_str()) {
2186 MergeOutcome {
2187 mode,
2188 ok: false,
2189 detail: format!(
2190 "{} has {} checked out, not the base branch {base}",
2191 repo.display(),
2192 on.unwrap_or_else(|| "a detached HEAD".to_owned())
2193 ),
2194 }
2195 } else if !git::is_clean(&repo).await? {
2196 MergeOutcome {
2197 mode,
2198 ok: false,
2199 detail: format!("{} is dirty; refusing to merge", repo.display()),
2200 }
2201 } else {
2202 let out = git::merge_no_ff(&repo, &winner.branch, &message).await?;
2203 MergeOutcome {
2204 mode,
2205 ok: out.ok(),
2206 detail: if out.ok() { out.stdout } else { out.stderr },
2207 }
2208 }
2209 }
2210 MergeMode::Pr => {
2211 let remote = self.state.config.merge.remote.clone();
2212 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
2213 if !pushed.ok() {
2214 MergeOutcome {
2215 mode,
2216 ok: false,
2217 detail: pushed.stderr,
2218 }
2219 } else {
2220 let out = gh_pr_create(&winner.worktree, &base, &winner.branch, &message).await;
2221 match out {
2222 Ok(url) => MergeOutcome {
2223 mode,
2224 ok: true,
2225 detail: url,
2226 },
2227 Err(e) => MergeOutcome {
2228 mode,
2229 ok: false,
2230 detail: e.to_string(),
2231 },
2232 }
2233 }
2234 }
2235 };
2236
2237 self.state.status = match (mode, outcome.ok) {
2238 (MergeMode::None, _) => RunStatus::Ready,
2239 (_, true) => RunStatus::Merged,
2240 (_, false) => RunStatus::Blocked,
2241 };
2242 self.state.event(
2243 "merge",
2244 format!(
2245 "{:?}: {}",
2246 mode,
2247 outcome.detail.lines().next().unwrap_or("")
2248 ),
2249 );
2250 self.state.merge = Some(outcome);
2251 self.state.save()?;
2252
2253 if self.state.config.graph.land
2259 && mode == MergeMode::Pr
2260 && self.state.status == RunStatus::Merged
2261 {
2262 let url = self
2263 .state
2264 .merge
2265 .as_ref()
2266 .map(|m| m.detail.clone())
2267 .unwrap_or_default();
2268 let url = url.lines().next().unwrap_or("").trim().to_owned();
2269 if url.starts_with("http") {
2270 match land::land(&mut self.state, &url).await {
2273 Ok(pr) => {
2274 self.state.status = match pr.state {
2275 land::PrLifecycle::Merged => RunStatus::Merged,
2276 _ => RunStatus::Blocked,
2277 };
2278 }
2279 Err(e) => {
2280 self.state.status = RunStatus::Blocked;
2281 self.state.event("land", format!("gave up: {e}"));
2282 }
2283 }
2284 self.state.save()?;
2285 }
2286 }
2287 Ok(())
2288 }
2289
2290 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
2294 if let Some(existing) = self.state.seats.get(key)
2295 && existing.agent == agent
2296 {
2297 return existing.clone();
2298 }
2299 let fresh = SeatState::new(key, agent, self.state.seed);
2300 self.state.seats.insert(key.to_owned(), fresh.clone());
2301 fresh
2302 }
2303
2304 fn view(&self, c: &Candidate) -> CandidateView {
2306 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2307 .unwrap_or_default();
2308 let (patch, _) = blind::sanitize_patch(
2309 &format!("candidate {} patch", c.label),
2310 &raw,
2311 &self.state.config.blind,
2312 );
2313 CandidateView {
2314 label: c.label,
2315 branch: c.branch.clone(),
2316 summary: c.summary.clone(),
2317 stat: c.stat.clone(),
2318 patch,
2319 }
2320 }
2321
2322 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
2324 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
2325 prompt::judge(
2326 "(see above)",
2327 &views,
2328 self.roles.judges.len(),
2329 base_short,
2330 "en",
2331 )
2332 }
2333
2334 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
2341 let mut turns = Vec::new();
2342 for j in &self.state.judgements {
2343 if j.ranking.is_empty() {
2344 continue;
2345 }
2346 let reasons = j
2347 .reasons
2348 .iter()
2349 .map(|(k, v)| format!("- {k}: {v}"))
2350 .collect::<Vec<_>>()
2351 .join("\n");
2352 turns.push(Turn {
2353 who: format!("Judge {} (opening ranking)", j.judge),
2354 is_self: j.judge == self_idx + 1,
2355 body: format!(
2356 "Ranked {}{}{reasons}",
2357 j.ranking.iter().collect::<String>(),
2358 if reasons.is_empty() {
2359 ""
2360 } else {
2361 ", because:\n"
2362 }
2363 ),
2364 });
2365 }
2366 for t in self
2367 .state
2368 .deliberation
2369 .iter()
2370 .flat_map(|r| r.turns.iter())
2371 .chain(current)
2372 {
2373 turns.push(Turn {
2374 who: format!("Judge {}", t.judge),
2375 is_self: t.judge == self_idx + 1,
2376 body: t.body.clone(),
2377 });
2378 }
2379 turns
2380 }
2381}
2382
2383fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
2385 agent::has_session(spec.kind, seat, sessions)
2386}
2387
2388fn short(commit: &str) -> String {
2389 commit.chars().take(7).collect()
2390}
2391
2392fn make_executable(path: &Path) -> Result<()> {
2393 #[cfg(unix)]
2394 {
2395 use std::os::unix::fs::PermissionsExt as _;
2396 let mut perms = std::fs::metadata(path)?.permissions();
2397 perms.set_mode(0o755);
2398 std::fs::set_permissions(path, perms)?;
2399 }
2400 #[cfg(not(unix))]
2401 {
2402 let _ = path;
2403 }
2404 Ok(())
2405}
2406
2407async fn run_one(
2409 job: SeatJob,
2410 sem: Arc<Semaphore>,
2411 run: &str,
2412 node: &str,
2413 prompts: &Prompts,
2414 cache: Option<&Path>,
2415) -> (SeatState, AgentOutcome) {
2416 let (_, seat, out) = wave(vec![job], sem, run, node, prompts, cache)
2417 .await
2418 .pop()
2419 .expect("one job in, one result out");
2420 (seat, out)
2421}
2422
2423async fn wave(
2430 jobs: Vec<SeatJob>,
2431 sem: Arc<Semaphore>,
2432 run: &str,
2433 node: &str,
2434 prompts: &Prompts,
2435 cache: Option<&Path>,
2436) -> Vec<(usize, SeatState, AgentOutcome)> {
2437 let mut set = tokio::task::JoinSet::new();
2438 let overlay = prompts.overlay(node);
2439 for (i, mut job) in jobs.into_iter().enumerate() {
2440 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
2441 if cache.is_some() {
2442 job.prompt.push('\n');
2443 job.prompt.push_str(prompt::build_cache_note());
2444 }
2445 let sem = Arc::clone(&sem);
2446 let run = run.to_owned();
2447 let node = node.to_owned();
2448 let cache = cache.map(Path::to_path_buf);
2449 set.spawn(async move {
2450 let _permit = sem.acquire().await;
2451 let mut seat = job.seat;
2452 let out = agent::invoke(
2453 &job.spec,
2454 &mut seat,
2455 &Invocation {
2456 cwd: &job.cwd,
2457 prompt: &job.prompt,
2458 timeout: job.timeout,
2459 allow_write: job.allow_write,
2460 sessions: job.sessions,
2461 artifacts: &job.artifacts,
2462 stem: &job.stem,
2463 run: &run,
2464 node: &node,
2465 cache_dir: cache.as_deref(),
2466 },
2467 )
2468 .await;
2469 let out = match out {
2470 Ok(o) if o.usable() => AgentOutcome::Ok(o),
2471 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
2472 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
2480 Ok(o) if o.timed_out => AgentOutcome::Failed("timed out".to_owned()),
2481 Ok(o) => AgentOutcome::Failed(format!(
2482 "exited with {:?} and no usable output",
2483 o.exit_code
2484 )),
2485 Err(e) => AgentOutcome::Failed(e.to_string()),
2486 };
2487 (i, seat, out)
2488 });
2489 }
2490 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
2491 while let Some(joined) = set.join_next().await {
2492 let (i, seat, out) = match joined {
2493 Ok(v) => v,
2494 Err(e) => {
2495 tracing::error!("agent task panicked: {e}");
2496 continue;
2497 }
2498 };
2499 if collected.len() <= i {
2500 collected.resize_with(i + 1, || None);
2501 }
2502 collected[i] = Some((i, seat, out));
2503 }
2504 collected.into_iter().flatten().collect()
2505}
2506
2507fn retry_budget(full: Duration, nudged: bool) -> Duration {
2522 if nudged {
2523 (full / 4).max(Duration::from_secs(120)).min(full)
2524 } else {
2525 full
2526 }
2527}
2528
2529#[allow(clippy::too_many_arguments)]
2542async fn ask_json_wave<T>(
2543 jobs: Vec<SeatJob>,
2544 sem: Arc<Semaphore>,
2545 retries: usize,
2546 run: &str,
2547 node: &str,
2548 prompts: &Prompts,
2549 cache: Option<&Path>,
2550 losses: &mut Vec<QuotaLoss>,
2551 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
2552) -> Vec<(SeatState, Result<(T, AgentOutput)>)>
2553where
2554 T: serde::de::DeserializeOwned + Send + 'static,
2555{
2556 let n = jobs.len();
2557 let originals: Vec<SeatJob> = jobs;
2558 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
2559 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
2560 let mut pending: Vec<usize> = (0..n).collect();
2561
2562 for attempt in 0..=retries {
2563 if pending.is_empty() {
2564 break;
2565 }
2566 let mut batch = Vec::with_capacity(pending.len());
2567 for &i in &pending {
2568 let src = &originals[i];
2569 let (prompt, timeout) = if attempt == 0 {
2572 (src.prompt.clone(), src.timeout)
2573 } else {
2574 let why = done[i]
2575 .as_ref()
2576 .and_then(|r| r.as_ref().err().map(ToString::to_string))
2577 .unwrap_or_else(|| "no parsable answer".to_owned());
2578 let nudge = prompt::nudge(&why);
2579 let nudged = has_context(&src.spec, &seats[i], src.sessions);
2580 let prompt = if nudged {
2581 nudge
2582 } else {
2583 format!("{}\n\n---\n\n{}", src.prompt, nudge)
2584 };
2585 (prompt, retry_budget(src.timeout, nudged))
2586 };
2587 batch.push(SeatJob {
2588 spec: src.spec.clone(),
2589 seat: seats[i].clone(),
2590 cwd: src.cwd.clone(),
2591 prompt,
2592 timeout,
2593 allow_write: src.allow_write,
2594 sessions: src.sessions,
2595 artifacts: src.artifacts.clone(),
2596 stem: if attempt == 0 {
2597 src.stem.clone()
2598 } else {
2599 format!("{}-retry{attempt}", src.stem)
2600 },
2601 });
2602 }
2603
2604 let results = wave(batch, Arc::clone(&sem), run, node, prompts, cache).await;
2605 let mut still = Vec::new();
2606 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
2607 seats[i] = seat;
2608 let (parsed, quota) = match out {
2609 AgentOutcome::Ok(o) => (
2610 match verdict::extract_json::<T>(&o.text) {
2611 Ok(v) => match validate(&v) {
2612 Ok(()) => Ok((v, o)),
2613 Err(e) => Err(e),
2614 },
2615 Err(e) => Err(e),
2616 },
2617 false,
2618 ),
2619 AgentOutcome::Quota(o) => {
2620 losses.push(QuotaLoss {
2621 seat: originals[i].seat.key.clone(),
2622 node: node.to_owned(),
2623 at: Timestamp::now(),
2624 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2625 });
2626 (
2627 Err(anyhow::anyhow!("rate limited (quota); not retrying now")),
2628 true,
2629 )
2630 }
2631 AgentOutcome::Dropped(o) => {
2636 let why = o
2637 .dropped
2638 .as_ref()
2639 .map(|d| d.why.as_str())
2640 .unwrap_or("the CLI ended the stream without delivering its answer");
2641 (
2642 Err(anyhow::anyhow!("the CLI dropped the stream ({why})")),
2643 false,
2644 )
2645 }
2646 AgentOutcome::Failed(e) => (Err(anyhow::anyhow!(e)), false),
2647 };
2648 let failed = parsed.is_err();
2649 done[i] = Some(parsed);
2650 if failed && !quota {
2653 still.push(i);
2654 }
2655 }
2656 pending = still;
2657 }
2658
2659 seats
2660 .into_iter()
2661 .zip(done)
2662 .map(|(seat, res)| {
2663 (
2664 seat,
2665 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
2666 )
2667 })
2668 .collect()
2669}
2670
2671async fn run_commands(
2673 shell: &[String],
2674 commands: &[String],
2675 cwd: &Path,
2676 timeout: Duration,
2677) -> Vec<CommandOutcome> {
2678 let mut out = Vec::new();
2679 for command in commands {
2680 let started = Instant::now();
2681 let mut cmd = tokio::process::Command::new(&shell[0]);
2682 cmd.quiet();
2683 cmd.args(&shell[1..])
2684 .arg(command)
2685 .current_dir(cwd)
2686 .stdin(std::process::Stdio::null())
2687 .stdout(std::process::Stdio::piped())
2688 .stderr(std::process::Stdio::piped())
2689 .kill_on_drop(true);
2690 let spawned = cmd.spawn();
2691 let (code, body) = match spawned {
2692 Ok(child) => match tokio::time::timeout(timeout, child.wait_with_output()).await {
2693 Ok(Ok(o)) => {
2694 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
2695 body.push_str(&String::from_utf8_lossy(&o.stderr));
2696 (o.status.code(), body)
2697 }
2698 Ok(Err(e)) => (None, format!("failed to run: {e}")),
2699 Err(_) => (None, format!("timed out after {}s", timeout.as_secs())),
2700 },
2701 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
2702 };
2703 out.push(CommandOutcome {
2704 command: command.clone(),
2705 code,
2706 output_tail: tail(&body, OUTPUT_TAIL),
2707 duration_ms: started.elapsed().as_millis() as u64,
2708 });
2709 }
2710 out
2711}
2712
2713async fn gh_pr_create(cwd: &Path, base: &str, head: &str, body: &str) -> Result<String> {
2715 let title = body.lines().next().unwrap_or("magi run").to_owned();
2716 let out = tokio::process::Command::new("gh")
2717 .args([
2718 "pr", "create", "--base", base, "--head", head, "--title", &title, "--body", body,
2719 ])
2720 .current_dir(cwd)
2721 .stdin(std::process::Stdio::null())
2722 .output()
2723 .await
2724 .context("spawn gh")?;
2725 if out.status.success() {
2726 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2727 } else {
2728 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
2729 }
2730}
2731
2732pub async fn fold_run(state: &mut RunState, drop_winner: bool) -> Result<Vec<String>> {
2734 let repo = state.repo.clone();
2735 let root = state.worktree_root();
2736 let winner = state.tally.as_ref().map(|t| t.winner);
2737 let mut removed = Vec::new();
2738
2739 for i in 0..state.candidates.len() {
2740 let c = state.candidates[i].clone();
2741 let is_winner = Some(c.label) == winner;
2742 if is_winner && !drop_winner {
2743 continue;
2744 }
2745 if c.worktree.exists() {
2746 git::worktree_remove(&repo, &c.worktree).await.ok();
2747 removed.push(c.worktree.to_string_lossy().into_owned());
2748 }
2749 if git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
2750 git::branch_delete(&repo, &c.branch).await.ok();
2751 removed.push(c.branch.clone());
2752 }
2753 state.candidates[i].folded = true;
2754 }
2755
2756 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
2757 let path = name.path();
2758 let keep = !drop_winner
2759 && winner.is_some_and(|w| {
2760 path.file_name()
2761 .is_some_and(|n| n == format!("cand-{w}").as_str())
2762 });
2763 if keep {
2764 continue;
2765 }
2766 git::worktree_remove(&repo, &path).await.ok();
2767 removed.push(path.to_string_lossy().into_owned());
2768 }
2769
2770 if state.enabled_worktree_config && drop_winner {
2771 git::disable_worktree_config(&repo).await.ok();
2772 state.enabled_worktree_config = false;
2773 }
2774 state.save()?;
2775 Ok(removed)
2776}
2777
2778pub fn worst_open(state: &RunState) -> Option<Severity> {
2780 state
2781 .reviews
2782 .last()?
2783 .reviews
2784 .iter()
2785 .flat_map(|r| r.findings.iter())
2786 .map(|f| f.severity)
2787 .max()
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792 use super::retry_budget;
2793 use std::time::Duration;
2794
2795 fn secs(n: u64) -> Duration {
2796 Duration::from_secs(n)
2797 }
2798
2799 #[test]
2800 fn a_nudge_gets_a_quarter_of_the_budget() {
2801 assert_eq!(retry_budget(secs(1200), true), secs(300));
2803 assert_eq!(retry_budget(secs(3600), true), secs(900));
2804 }
2805
2806 #[test]
2807 fn a_resent_prompt_keeps_the_whole_budget() {
2808 assert_eq!(retry_budget(secs(1200), false), secs(1200));
2811 assert_eq!(retry_budget(secs(60), false), secs(60));
2812 }
2813
2814 #[test]
2815 fn the_floor_never_exceeds_the_original_budget() {
2816 assert_eq!(retry_budget(secs(60), true), secs(60));
2820 assert_eq!(retry_budget(secs(480), true), secs(120));
2821 assert_eq!(retry_budget(secs(0), true), secs(0));
2822 }
2823}