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(jobs, Arc::clone(&self.sem), &run_id, "implement", &prompts).await;
646 self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
647 .await;
648
649 for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
650 let seat_key = seat.key.clone();
651 self.state.seats.insert(seat.key.clone(), seat);
652 let label = self.state.candidates[i].label;
653 let worktree = self.state.candidates[i].worktree.clone();
654 let base = self.state.base_commit.clone();
655
656 let (summary, duration, failed) = match out {
657 AgentOutcome::Ok(o) => {
658 let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
659 let failed = (!o.usable()).then(|| {
660 if o.timed_out {
661 "agent timed out".to_owned()
662 } else {
663 format!("agent exited with {:?}", o.exit_code)
664 }
665 });
666 (text, o.duration_ms, failed)
667 }
668 AgentOutcome::Dropped(o) => {
674 let why = o
675 .dropped
676 .as_ref()
677 .map(|d| d.why.as_str())
678 .unwrap_or("the CLI ended the stream without delivering its answer");
679 (
680 String::new(),
681 o.duration_ms,
682 Some(format!("the CLI dropped the stream ({why})")),
683 )
684 }
685 AgentOutcome::Quota(o) => {
686 self.state.quota.push(QuotaLoss {
687 seat: seat_key,
688 node: "implement".to_owned(),
689 at: Timestamp::now(),
690 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
691 });
692 (
693 String::new(),
694 o.duration_ms,
695 Some("rate limited (quota); produced no change".to_owned()),
696 )
697 }
698 AgentOutcome::Failed(e) => (String::new(), 0, Some(e)),
699 };
700
701 let rescued = git::commit_all(
704 &worktree,
705 &format!("magi: candidate {label} (uncommitted work)"),
706 )
707 .await
708 .unwrap_or(false);
709 let commits = git::commits_ahead(&worktree, &base, "HEAD")
710 .await
711 .unwrap_or(0);
712 let patch = git::diff(&worktree, &base, "HEAD")
713 .await
714 .unwrap_or_default();
715 let stat = git::diff_stat(&worktree, &base, "HEAD")
716 .await
717 .unwrap_or_default();
718 let files = git::changed_files(&worktree, &base, "HEAD")
719 .await
720 .map(|f| f.len())
721 .unwrap_or(0);
722 write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
723
724 let c = &mut self.state.candidates[i];
725 c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
726 c.stat = stat;
727 c.files = files;
728 c.commits = commits;
729 c.duration_ms = duration;
730 c.empty = commits == 0 || patch.trim().is_empty();
731 c.failed = match failed {
734 Some(_) if c.empty => failed,
735 _ => None,
736 };
737 let note = match (&c.failed, c.empty, rescued) {
738 (Some(e), _, _) => format!("candidate {label}: {e}"),
739 (None, true, _) => format!("candidate {label}: no change produced"),
740 (None, false, true) => {
741 format!(
742 "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
743 )
744 }
745 (None, false, false) => {
746 format!("candidate {label}: {files} files, {commits} commits")
747 }
748 };
749 self.state.event("implement", note);
750 self.state.save()?;
751 }
752
753 self.after_implement()
754 }
755
756 async fn resume_undelivered(
784 &mut self,
785 results: &mut [(usize, SeatState, AgentOutcome)],
786 sent: &[SeatJob],
787 prompts: &Prompts,
788 run_id: &str,
789 ) {
790 for (wi, seat, out) in results.iter_mut() {
791 let Some(dropped) = (match &*out {
792 AgentOutcome::Dropped(o) => o.dropped.clone(),
793 _ => None,
794 }) else {
795 continue;
796 };
797 let Some(job) = sent.get(*wi) else { continue };
798 if !git::is_clean(&job.cwd).await.unwrap_or(true) {
800 self.state.event(
801 "implement",
802 format!(
803 "{}: the CLI dropped the stream after {} output tokens ({}), but the \
804 work is in the tree",
805 seat.key, dropped.output_tokens, dropped.why
806 ),
807 );
808 continue;
809 }
810 if !has_context(&job.spec, seat, job.sessions) {
818 self.state.event(
819 "implement",
820 format!(
821 "{}: the CLI dropped the stream after {} output tokens ({}), but there \
822 is no session left to resume",
823 seat.key, dropped.output_tokens, dropped.why
824 ),
825 );
826 continue;
827 }
828 self.state.event(
829 "implement",
830 format!(
831 "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
832 conversation",
833 seat.key, dropped.output_tokens, dropped.why
834 ),
835 );
836 let mut retry = job.clone();
837 retry.seat = seat.clone();
838 retry.prompt = prompt::resume_after_drop(&dropped.why);
839 retry.timeout = retry_budget(job.timeout, true);
840 retry.stem = format!("{}-resume", job.stem);
841 let (resumed_seat, resumed) =
842 run_one(retry, Arc::clone(&self.sem), run_id, "implement", prompts).await;
843 *seat = resumed_seat;
844 *out = resumed;
845 }
846 }
847
848 fn after_implement(&mut self) -> Result<()> {
849 if self.state.leaks.is_empty() {
851 let cfg = self.state.config.blind.clone();
852 let mut leaks = Vec::new();
853 for c in &self.state.candidates {
854 let Some(patch) =
855 crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
856 else {
857 continue;
858 };
859 leaks.extend(blind::scan(
860 &format!("candidate {} patch", c.label),
861 &patch,
862 &cfg.vendor_tokens,
863 ));
864 }
865 if !leaks.is_empty() {
866 let summary = leaks
867 .iter()
868 .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
869 .collect::<Vec<_>>()
870 .join(", ");
871 match cfg.on_leak {
872 LeakPolicy::Fail => {
873 self.state.status = RunStatus::Failed;
874 self.state
875 .event("blind", format!("vendor text in a patch: {summary}"));
876 self.state.leaks = leaks;
877 self.state.save()?;
878 bail!(
879 "blind.on_leak = \"fail\" and vendor text reached a \
880 judged patch: {summary}"
881 );
882 }
883 LeakPolicy::Redact => self.state.event(
884 "blind",
885 format!("redacting vendor text for judging: {summary}"),
886 ),
887 LeakPolicy::Warn => self.state.event(
888 "blind",
889 format!("vendor text present in a judged patch (shown as-is): {summary}"),
890 ),
891 }
892 self.state.leaks = leaks;
893 }
894 }
895
896 if self.state.viable().is_empty() {
897 self.state.status = RunStatus::Failed;
898 self.state.save()?;
899 bail!("no candidate produced a change; nothing to judge");
900 }
901 self.state.status = RunStatus::Judging;
902 self.state.save()?;
903 Ok(())
904 }
905
906 async fn judge(&mut self) -> Result<()> {
909 let run_id = self.state.id.clone();
914 let prompts = self.state.config.prompts.clone();
915 if !self.state.judgements.is_empty() {
916 return Ok(());
917 }
918 self.state.status = RunStatus::Judging;
919 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
920 if viable.len() == 1 {
921 self.state.event(
922 "judge",
923 format!(
924 "only candidate {} produced a change; judging skipped",
925 viable[0].label
926 ),
927 );
928 self.state.save()?;
929 return Ok(());
930 }
931
932 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
933 let language = self.state.config.graph.language.clone();
934 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
935 let sessions = self.state.config.graph.sessions;
936 let artifacts = agent::artifacts_dir(&self.state.dir());
937 let root = self.state.worktree_root();
938 let base_short = short(&self.state.base_commit);
939
940 let mut jobs = Vec::new();
941 let mut orders = Vec::new();
942 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
943 let order = blind::presentation_order(viable.len(), j, self.state.seed);
944 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
945 orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
946 let seat_key = format!("judge-{}", j + 1);
947 let seat = self.seat(&seat_key, &spec.id);
948 jobs.push(SeatJob {
949 prompt: prompt::judge(
950 &self.state.instruction,
951 &views,
952 self.roles.judges.len(),
953 &base_short,
954 &language,
955 ),
956 spec,
957 seat,
958 cwd: root.join(format!("judge-{}", j + 1)),
959 timeout,
960 allow_write: false,
961 sessions,
962 artifacts: artifacts.clone(),
963 stem: format!("judge-{}", j + 1),
964 });
965 }
966
967 self.state.event(
968 "judge",
969 format!(
970 "{} judges ranking {} candidates blind",
971 jobs.len(),
972 viable.len()
973 ),
974 );
975 let labels_for_check = labels.clone();
976 let mut quota_losses = Vec::new();
977 let results = ask_json_wave::<Ranking>(
978 jobs,
979 Arc::clone(&self.sem),
980 self.state.config.graph.retries,
981 &run_id,
982 "judge",
983 &prompts,
984 &mut quota_losses,
985 &move |r: &Ranking| r.validate(&labels_for_check),
986 )
987 .await;
988 self.state.quota.extend(quota_losses);
989
990 for (j, (seat, res)) in results.into_iter().enumerate() {
991 let agent_id = seat.agent.clone();
992 self.state.seats.insert(seat.key.clone(), seat);
993 let mut record = Judgement {
994 judge: j + 1,
995 seat: format!("judge-{}", j + 1),
996 agent: agent_id,
997 ranking: Vec::new(),
998 reasons: BTreeMap::new(),
999 confidence: None,
1000 order: orders[j].clone(),
1001 failed: None,
1002 duration_ms: 0,
1003 };
1004 match res {
1005 Ok((ranking, out)) => {
1006 record.ranking = ranking.normalized();
1007 record.reasons = ranking.reasons;
1008 record.confidence = ranking.confidence;
1009 record.duration_ms = out.duration_ms;
1010 self.state.event(
1011 "judge",
1012 format!(
1013 "judge {} ranked {}",
1014 j + 1,
1015 record.ranking.iter().collect::<String>()
1016 ),
1017 );
1018 }
1019 Err(e) => {
1020 record.failed = Some(e.to_string());
1021 self.state
1022 .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
1023 }
1024 }
1025 self.state.judgements.push(record);
1026 self.state.save()?;
1027 }
1028 Ok(())
1029 }
1030
1031 async fn deliberate(&mut self) -> Result<()> {
1034 let run_id = self.state.id.clone();
1039 let prompts = self.state.config.prompts.clone();
1040 if !self.state.deliberation.is_empty() {
1041 return Ok(());
1042 }
1043 let tops: Vec<char> = self
1044 .state
1045 .judgements
1046 .iter()
1047 .filter_map(|j| j.ranking.first().copied())
1048 .collect();
1049 let rounds = self.state.config.graph.deliberate_rounds;
1050 if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
1051 if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
1052 self.state.event(
1053 "deliberate",
1054 format!("judges agreed on {} outright; no deliberation", tops[0]),
1055 );
1056 }
1057 self.state.status = RunStatus::Voting;
1058 self.state.save()?;
1059 return Ok(());
1060 }
1061
1062 self.state.status = RunStatus::Deliberating;
1063 self.state.event(
1064 "deliberate",
1065 format!(
1066 "split: first choices were {} — opening {rounds} round(s)",
1067 tops.iter().collect::<String>()
1068 ),
1069 );
1070
1071 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1072 let language = self.state.config.graph.language.clone();
1073 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1074 let sessions = self.state.config.graph.sessions;
1075 let artifacts = agent::artifacts_dir(&self.state.dir());
1076 let root = self.state.worktree_root();
1077 let base_short = short(&self.state.base_commit);
1078
1079 for round in 1..=rounds {
1083 let mut turns: Vec<DeliberationTurn> = Vec::new();
1084 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1085 if self.state.judgements[j].failed.is_some() {
1086 continue;
1087 }
1088 let seat_key = format!("judge-{}", j + 1);
1089 let mut seat = self.seat(&seat_key, &spec.id);
1090 let transcript = self.transcript(&turns, j);
1091 let context = if has_context(&spec, &seat, sessions) {
1092 None
1093 } else {
1094 Some(self.candidate_block(&viable, &base_short))
1095 };
1096 let text = prompt::deliberate(
1097 &self.state.instruction,
1098 context.as_deref(),
1099 &transcript,
1100 round,
1101 rounds,
1102 &language,
1103 );
1104 let job = SeatJob {
1105 spec,
1106 seat: seat.clone(),
1107 prompt: text,
1108 cwd: root.join(format!("judge-{}", j + 1)),
1109 timeout,
1110 allow_write: false,
1111 sessions,
1112 artifacts: artifacts.clone(),
1113 stem: format!("delib-{round}-judge-{}", j + 1),
1114 };
1115 let (updated, out) =
1116 run_one(job, Arc::clone(&self.sem), &run_id, "deliberate", &prompts).await;
1117 seat = updated;
1118 let agent_id = seat.agent.clone();
1119 let seat_key = seat.key.clone();
1120 self.state.seats.insert(seat.key.clone(), seat);
1121 let body = match out {
1122 AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
1123 AgentOutcome::Dropped(o) => {
1127 let why =
1128 o.dropped.as_ref().map(|d| d.why.as_str()).unwrap_or(
1129 "the CLI ended the stream without delivering its answer",
1130 );
1131 self.state.event(
1132 "deliberate",
1133 format!(
1134 "judge {} skipped: the CLI dropped the stream ({why})",
1135 j + 1
1136 ),
1137 );
1138 continue;
1139 }
1140 AgentOutcome::Quota(o) => {
1141 self.state.quota.push(QuotaLoss {
1142 seat: seat_key,
1143 node: "deliberate".to_owned(),
1144 at: Timestamp::now(),
1145 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1146 });
1147 self.state.event(
1148 "deliberate",
1149 format!("judge {} skipped: rate limited (quota)", j + 1),
1150 );
1151 continue;
1152 }
1153 AgentOutcome::Failed(e) => {
1154 self.state
1155 .event("deliberate", format!("judge {} skipped: {e}", j + 1));
1156 continue;
1157 }
1158 };
1159 let tentative = verdict::extract_json::<Position>(&body)
1160 .ok()
1161 .and_then(|p| p.tentative)
1162 .and_then(|s| s.trim().chars().next())
1163 .map(|c| c.to_ascii_uppercase());
1164 self.state.event(
1165 "deliberate",
1166 format!(
1167 "round {round}: judge {} now favours {}",
1168 j + 1,
1169 tentative.map_or("—".to_owned(), |c| c.to_string())
1170 ),
1171 );
1172 turns.push(DeliberationTurn {
1173 judge: j + 1,
1174 agent: agent_id,
1175 body: blind::sanitize_prose(&body, &self.state.config.blind),
1176 tentative,
1177 });
1178 }
1179 self.state
1180 .deliberation
1181 .push(DeliberationRound { round, turns });
1182 self.state.save()?;
1183 }
1184
1185 self.state.status = RunStatus::Voting;
1186 self.state.save()?;
1187 Ok(())
1188 }
1189
1190 async fn vote(&mut self) -> Result<()> {
1193 let run_id = self.state.id.clone();
1198 let prompts = self.state.config.prompts.clone();
1199 if !self.state.votes.is_empty() {
1200 return Ok(());
1201 }
1202 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1203 if viable.len() == 1 {
1204 return Ok(());
1205 }
1206 self.state.status = RunStatus::Voting;
1207
1208 let language = self.state.config.graph.language.clone();
1209 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1210 let sessions = self.state.config.graph.sessions;
1211 let artifacts = agent::artifacts_dir(&self.state.dir());
1212 let root = self.state.worktree_root();
1213 let base_short = short(&self.state.base_commit);
1214 let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1215
1216 let mut jobs = Vec::new();
1217 let mut seats_at = Vec::new();
1218 for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1219 if self
1220 .state
1221 .judgements
1222 .get(j)
1223 .is_some_and(|r| r.failed.is_some())
1224 {
1225 continue;
1226 }
1227 let seat_key = format!("judge-{}", j + 1);
1228 let seat = self.seat(&seat_key, &spec.id);
1229 let mut text = prompt::final_vote(&viable, &language);
1230 if !has_context(&spec, &seat, sessions) {
1231 text = format!(
1232 "{}\n\n# Candidates\n\n{}",
1233 text,
1234 self.candidate_block(&candidates, &base_short)
1235 );
1236 }
1237 jobs.push(SeatJob {
1238 spec,
1239 seat,
1240 prompt: text,
1241 cwd: root.join(format!("judge-{}", j + 1)),
1242 timeout,
1243 allow_write: false,
1244 sessions,
1245 artifacts: artifacts.clone(),
1246 stem: format!("vote-judge-{}", j + 1),
1247 });
1248 seats_at.push(j);
1249 }
1250
1251 self.state.event(
1252 "vote",
1253 format!(
1254 "collecting {} final votes one by one, privately",
1255 jobs.len()
1256 ),
1257 );
1258 let allowed = viable.clone();
1259 let mut quota_losses = Vec::new();
1260 let results = ask_json_wave::<FinalVote>(
1261 jobs,
1262 Arc::clone(&self.sem),
1263 self.state.config.graph.retries,
1264 &run_id,
1265 "vote",
1266 &prompts,
1267 &mut quota_losses,
1268 &move |v: &FinalVote| match v.label() {
1269 Some(c) if allowed.contains(&c) => Ok(()),
1270 other => bail!("vote {other:?} is not one of {allowed:?}"),
1271 },
1272 )
1273 .await;
1274 self.state.quota.extend(quota_losses);
1275
1276 for (&j, (seat, res)) in seats_at.iter().zip(results) {
1277 let agent_id = seat.agent.clone();
1278 self.state.seats.insert(seat.key.clone(), seat);
1279 let initial = self
1280 .state
1281 .judgements
1282 .get(j)
1283 .and_then(|r| r.ranking.first().copied());
1284 let mut record = VoteRecord {
1285 judge: j + 1,
1286 agent: agent_id,
1287 vote: None,
1288 reason: String::new(),
1289 changed: false,
1290 };
1291 match res {
1292 Ok((v, _)) => {
1293 record.vote = v.label();
1294 record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1295 record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
1296 self.state.event(
1297 "vote",
1298 format!(
1299 "judge {} voted {}{}",
1300 j + 1,
1301 record.vote.unwrap_or('?'),
1302 if record.changed { " (changed)" } else { "" }
1303 ),
1304 );
1305 }
1306 Err(e) => {
1307 self.state
1308 .event("vote", format!("judge {} cast no vote: {e}", j + 1));
1309 }
1310 }
1311 self.state.votes.push(record);
1312 self.state.save()?;
1313 }
1314 Ok(())
1315 }
1316
1317 fn tally(&mut self) -> Result<()> {
1320 if self.state.tally.is_some() {
1321 return Ok(());
1322 }
1323 let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1324 let tops: Vec<char> = self
1325 .state
1326 .judgements
1327 .iter()
1328 .filter_map(|j| j.ranking.first().copied())
1329 .collect();
1330 let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
1331
1332 let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1335 let mut cast: Vec<char> = Vec::new();
1336 for (i, j) in self.state.judgements.iter().enumerate() {
1337 let vote = self
1338 .state
1339 .votes
1340 .iter()
1341 .find(|v| v.judge == i + 1)
1342 .and_then(|v| v.vote)
1343 .or_else(|| j.ranking.first().copied());
1344 if let Some(v) = vote {
1345 *first_choice.entry(v).or_insert(0) += 1;
1346 cast.push(v);
1347 }
1348 }
1349
1350 let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1351 for j in &self.state.judgements {
1352 let n = j.ranking.len();
1353 for (pos, label) in j.ranking.iter().enumerate() {
1354 *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
1355 }
1356 }
1357
1358 let best = first_choice.values().copied().max().unwrap_or(0);
1359 let mut leaders: Vec<char> = first_choice
1360 .iter()
1361 .filter(|(_, v)| **v == best)
1362 .map(|(k, _)| *k)
1363 .collect();
1364 let mut tie_break = None;
1365 if leaders.len() > 1 {
1366 let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
1367 let borda_leaders: Vec<char> = leaders
1368 .iter()
1369 .copied()
1370 .filter(|l| borda[l] == top_borda)
1371 .collect();
1372 tie_break = Some(if borda_leaders.len() == 1 {
1373 format!(
1374 "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
1375 leaders.len()
1376 )
1377 } else {
1378 format!(
1379 "{} way tie on both first-choice votes and Borda points, broken by label order",
1380 leaders.len()
1381 )
1382 });
1383 leaders = borda_leaders;
1384 leaders.sort_unstable();
1385 }
1386 let winner = *leaders
1387 .first()
1388 .or(viable.first())
1389 .context("no candidate to declare a winner from")?;
1390
1391 let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
1392 let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
1393 let deliberated = !self.state.deliberation.is_empty();
1394
1395 let quota_seats: std::collections::BTreeSet<&str> =
1399 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1400 let mut present = 0usize;
1401 for (i, j) in self.state.judgements.iter().enumerate() {
1402 if quota_seats.contains(j.seat.as_str()) {
1403 continue;
1404 }
1405 let ranked = !j.ranking.is_empty() && j.failed.is_none();
1406 let voted = self
1407 .state
1408 .votes
1409 .iter()
1410 .any(|v| v.judge == i + 1 && v.vote.is_some());
1411 if ranked || voted {
1412 present += 1;
1413 }
1414 }
1415 let judges_total = self.roles.judges.len();
1419 let needs_quorum = viable.len() > 1;
1420 let quorum = if needs_quorum {
1421 judges_total / 2 + 1
1422 } else {
1423 0
1424 };
1425 let met_quorum = !needs_quorum || present >= quorum;
1426
1427 self.state.event(
1428 "tally",
1429 format!(
1430 "winner {winner} — votes {} | initial {} | {} changed | {present}/{judges_total} judges{}",
1431 first_choice
1432 .iter()
1433 .map(|(k, v)| format!("{k}:{v}"))
1434 .collect::<Vec<_>>()
1435 .join(" "),
1436 if unanimous_initial {
1437 "unanimous"
1438 } else {
1439 "split"
1440 },
1441 changed_votes,
1442 if met_quorum {
1443 String::new()
1444 } else {
1445 format!(" — below quorum ({quorum} required)")
1446 },
1447 ),
1448 );
1449 if !met_quorum {
1450 self.state.event(
1451 "stall",
1452 format!(
1453 "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
1454 the run stops here, resumable"
1455 ),
1456 );
1457 }
1458 self.state.tally = Some(Tally {
1459 first_choice,
1460 borda,
1461 winner,
1462 rankings: tops.len(),
1463 unanimous_initial,
1464 deliberated,
1465 changed_votes,
1466 unanimous_final,
1467 tie_break,
1468 judges: judges_total,
1469 present,
1470 quorum,
1471 met_quorum,
1472 });
1473 self.state.status = if met_quorum {
1474 RunStatus::Reviewing
1475 } else {
1476 RunStatus::Stalled
1477 };
1478 self.state.save()?;
1479 Ok(())
1480 }
1481
1482 #[allow(clippy::too_many_lines)]
1503 async fn recover_stall(&mut self) -> Result<bool> {
1504 let run_id = self.state.id.clone();
1509 let prompts = self.state.config.prompts.clone();
1510 let quota_seats: BTreeSet<&str> =
1515 self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1516 let absent: Vec<String> = self
1517 .state
1518 .judgements
1519 .iter()
1520 .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
1521 .map(|j| j.seat.clone())
1522 .collect();
1523 if absent.is_empty() {
1524 return Ok(false);
1525 }
1526 let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1527 if viable.len() <= 1 {
1528 return Ok(false);
1529 }
1530 let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1531 let language = self.state.config.graph.language.clone();
1532 let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1533 let sessions = self.state.config.graph.sessions;
1534 let artifacts = agent::artifacts_dir(&self.state.dir());
1535 let root = self.state.worktree_root();
1536 let base_short = short(&self.state.base_commit);
1537 let candidates: Vec<Candidate> = viable.clone();
1538
1539 let mut positions: Vec<usize> = absent
1541 .iter()
1542 .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
1543 .collect();
1544 if positions.is_empty() {
1545 return Ok(false);
1546 }
1547 positions.sort_unstable();
1548 positions.dedup();
1549
1550 let mut judge_jobs = Vec::new();
1552 for &j in &positions {
1553 let order = blind::presentation_order(viable.len(), j, self.state.seed);
1554 let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1555 let seat_key = format!("judge-{}", j + 1);
1556 let spec = self.roles.judges[j].clone();
1557 let seat = self.seat(&seat_key, &spec.id);
1558 judge_jobs.push(SeatJob {
1559 spec,
1560 seat,
1561 prompt: prompt::judge(
1562 &self.state.instruction,
1563 &views,
1564 self.roles.judges.len(),
1565 &base_short,
1566 &language,
1567 ),
1568 cwd: root.join(seat_key),
1569 timeout,
1570 allow_write: false,
1571 sessions,
1572 artifacts: artifacts.clone(),
1573 stem: format!("judge-{}-recover", j + 1),
1574 });
1575 }
1576
1577 let labels_for_check = labels.clone();
1578 let mut judge_losses = Vec::new();
1579 let results = ask_json_wave::<Ranking>(
1580 judge_jobs,
1581 Arc::clone(&self.sem),
1582 self.state.config.graph.retries,
1583 &run_id,
1584 "judge",
1585 &prompts,
1586 &mut judge_losses,
1587 &move |r: &Ranking| r.validate(&labels_for_check),
1588 )
1589 .await;
1590
1591 let mut recovered: BTreeSet<usize> = BTreeSet::new();
1593 for (&j, (seat, res)) in positions.iter().zip(results) {
1594 self.state.seats.insert(seat.key.clone(), seat);
1595 let record = &mut self.state.judgements[j];
1596 match res {
1597 Ok((ranking, out)) => {
1598 record.ranking = ranking.normalized();
1599 record.reasons = ranking.reasons;
1600 record.confidence = ranking.confidence;
1601 record.failed = None;
1602 record.duration_ms = out.duration_ms;
1603 recovered.insert(j);
1604 self.state.event(
1605 "recover",
1606 format!("judge {} ranked again after the limit", j + 1),
1607 );
1608 }
1609 Err(e) => {
1610 self.state
1611 .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
1612 }
1613 }
1614 }
1615
1616 let mut vote_jobs = Vec::new();
1618 let mut vote_pos: Vec<usize> = Vec::new();
1619 for &j in &recovered {
1620 let seat_key = format!("judge-{}", j + 1);
1621 let spec = self.roles.judges[j].clone();
1622 let seat = self.seat(&seat_key, &spec.id);
1623 let mut text = prompt::final_vote(&labels, &language);
1624 if !has_context(&spec, &seat, sessions) {
1625 text = format!(
1626 "{}\n\n# Candidates\n\n{}",
1627 text,
1628 self.candidate_block(&candidates, &base_short)
1629 );
1630 }
1631 vote_jobs.push(SeatJob {
1632 spec,
1633 seat,
1634 prompt: text,
1635 cwd: root.join(seat_key),
1636 timeout,
1637 allow_write: false,
1638 sessions,
1639 artifacts: artifacts.clone(),
1640 stem: format!("vote-judge-{}-recover", j + 1),
1641 });
1642 vote_pos.push(j);
1643 }
1644 let allowed = labels.clone();
1645 let mut vote_losses = Vec::new();
1646 let votes = ask_json_wave::<FinalVote>(
1647 vote_jobs,
1648 Arc::clone(&self.sem),
1649 self.state.config.graph.retries,
1650 &run_id,
1651 "vote",
1652 &prompts,
1653 &mut vote_losses,
1654 &move |v: &FinalVote| match v.label() {
1655 Some(c) if allowed.contains(&c) => Ok(()),
1656 other => bail!("vote {other:?} is not one of {allowed:?}"),
1657 },
1658 )
1659 .await;
1660 for (&j, (seat, res)) in vote_pos.iter().zip(votes) {
1661 let agent_id = seat.agent.clone();
1662 self.state.seats.insert(seat.key.clone(), seat);
1663 match res {
1664 Ok((v, _)) => {
1665 if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
1666 rec.vote = v.label();
1667 rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1668 } else {
1669 self.state.votes.push(VoteRecord {
1670 judge: j + 1,
1671 agent: agent_id,
1672 vote: v.label(),
1673 reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
1674 changed: false,
1675 });
1676 }
1677 self.state.event(
1678 "recover",
1679 format!("judge {} voted again after the limit", j + 1),
1680 );
1681 }
1682 Err(e) => {
1683 self.state
1684 .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
1685 }
1686 }
1687 }
1688
1689 if !recovered.is_empty() {
1693 let recovered_keys: BTreeSet<String> = recovered
1694 .iter()
1695 .map(|&j| format!("judge-{}", j + 1))
1696 .collect();
1697 self.state
1698 .quota
1699 .retain(|q| !recovered_keys.contains(&q.seat));
1700 }
1701
1702 self.state.tally = None;
1704 self.tally()?;
1705 Ok(self
1706 .state
1707 .tally
1708 .as_ref()
1709 .map(|t| t.met_quorum)
1710 .unwrap_or(false))
1711 }
1712
1713 async fn fold_losers(&mut self) -> Result<()> {
1716 let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
1717 return Ok(());
1718 };
1719 let repo = self.state.repo.clone();
1720 let mut folded = Vec::new();
1721 for i in 0..self.state.candidates.len() {
1722 let c = &self.state.candidates[i];
1723 if c.label == winner || c.folded {
1724 continue;
1725 }
1726 let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
1727 git::worktree_remove(&repo, &wt).await.ok();
1728 git::branch_delete(&repo, &branch).await.ok();
1729 self.state.candidates[i].folded = true;
1730 folded.push(label.to_string());
1731 }
1732 let root = self.state.worktree_root();
1734 for j in 1..=self.roles.judges.len() {
1735 let wt = root.join(format!("judge-{j}"));
1736 if wt.exists() {
1737 git::worktree_remove(&repo, &wt).await.ok();
1738 }
1739 }
1740 if !folded.is_empty() {
1741 self.state
1742 .event("fold", format!("folded candidates {}", folded.join(", ")));
1743 self.state.save()?;
1744 }
1745 Ok(())
1746 }
1747
1748 async fn review_loop(&mut self) -> Result<()> {
1751 let run_id = self.state.id.clone();
1756 let prompts = self.state.config.prompts.clone();
1757 let Some(winner) = self.state.winner().cloned() else {
1758 return Ok(());
1759 };
1760 let max_rounds = self.state.config.graph.review_rounds;
1761 if max_rounds == 0 || self.state.reviews.iter().any(|r| r.clean) {
1762 self.state.status = RunStatus::Gating;
1763 self.state.save()?;
1764 return Ok(());
1765 }
1766 self.state.status = RunStatus::Reviewing;
1767
1768 let repo = self.state.repo.clone();
1769 let root = self.state.worktree_root();
1770 let language = self.state.config.graph.language.clone();
1771 let sessions = self.state.config.graph.sessions;
1772 let artifacts = agent::artifacts_dir(&self.state.dir());
1773 let base_short = short(&self.state.base_commit);
1774 let reviewers = self.roles.reviewers.clone();
1775 let base = self.state.base_commit.clone();
1776 let shell = self.state.config.shell();
1777
1778 let mut prev_e2e: Option<String> = None;
1779 for round in (self.state.reviews.len() + 1)..=max_rounds {
1780 let head = git::rev_parse(&winner.worktree, "HEAD").await?;
1781 let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
1782 let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
1783
1784 let mut jobs = Vec::new();
1788 for (r, spec) in reviewers.iter().cloned().enumerate() {
1789 let wt = root.join(format!("review-{}", r + 1));
1790 if wt.exists() {
1791 git::reset_detached(&wt, &head).await?;
1792 } else {
1793 git::worktree_add_detached(&repo, &wt, &head).await?;
1794 }
1795 let seat_key = format!("review-{}", r + 1);
1796 let seat = self.seat(&seat_key, &spec.id);
1797 jobs.push(SeatJob {
1798 prompt: prompt::review(&prompt::ReviewCtx {
1799 instruction: &self.state.instruction,
1800 branch: &winner.branch,
1801 base_short: &base_short,
1802 stat: &stat,
1803 patch: &patch,
1804 e2e: prev_e2e.as_deref(),
1805 reviewers: reviewers.len(),
1806 round,
1807 rounds: max_rounds,
1808 competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
1811 language: &language,
1812 }),
1813 spec,
1814 seat,
1815 cwd: wt,
1816 timeout: Duration::from_secs(self.state.config.graph.timeout_review),
1817 allow_write: false,
1818 sessions,
1819 artifacts: artifacts.clone(),
1820 stem: format!("review-{round}-{}", r + 1),
1821 });
1822 }
1823
1824 self.state.event(
1825 "review",
1826 format!(
1827 "round {round}: {} reviewers on {}",
1828 jobs.len(),
1829 short(&head)
1830 ),
1831 );
1832 let mut quota_losses = Vec::new();
1833 let results = ask_json_wave::<Review>(
1834 jobs,
1835 Arc::clone(&self.sem),
1836 self.state.config.graph.retries,
1837 &run_id,
1838 "review",
1839 &prompts,
1840 &mut quota_losses,
1841 &|_: &Review| Ok(()),
1842 )
1843 .await;
1844 self.state.quota.extend(quota_losses);
1845
1846 let mut records = Vec::new();
1847 let mut all_findings = Vec::new();
1848 for (r, (seat, res)) in results.into_iter().enumerate() {
1849 let agent_id = seat.agent.clone();
1850 self.state.seats.insert(seat.key.clone(), seat);
1851 let mut record = ReviewRecord {
1852 reviewer: r + 1,
1853 agent: agent_id,
1854 summary: String::new(),
1855 findings: Vec::new(),
1856 failed: None,
1857 duration_ms: 0,
1858 };
1859 match res {
1860 Ok((review, out)) => {
1861 record.summary = review.summary;
1862 record.duration_ms = out.duration_ms;
1863 for (n, mut f) in review.findings.into_iter().enumerate() {
1864 f.id = format!("R{round}-{}-{}", r + 1, n + 1);
1867 all_findings.push(f.clone());
1868 record.findings.push(f);
1869 }
1870 self.state.event(
1871 "review",
1872 format!(
1873 "round {round}: reviewer {} raised {} finding(s)",
1874 r + 1,
1875 record.findings.len()
1876 ),
1877 );
1878 }
1879 Err(e) => {
1880 record.failed = Some(e.to_string());
1881 self.state.event(
1882 "review",
1883 format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
1884 );
1885 }
1886 }
1887 records.push(record);
1888 }
1889
1890 let e2e = run_commands(
1891 &shell,
1892 &self.state.config.verify.e2e,
1893 &winner.worktree,
1894 Duration::from_secs(self.state.config.graph.timeout_review),
1895 )
1896 .await;
1897 for o in &e2e {
1898 self.state.event(
1899 "verify",
1900 format!(
1901 "round {round}: `{}` -> {}",
1902 o.command,
1903 if o.ok() {
1904 "pass".to_owned()
1905 } else {
1906 format!("FAIL ({:?})", o.code)
1907 }
1908 ),
1909 );
1910 }
1911 let e2e_failures: String = e2e
1912 .iter()
1913 .filter(|o| !o.ok())
1914 .map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
1915 .collect();
1916
1917 let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
1918 let clean = blocking == 0 && e2e.iter().all(CommandOutcome::ok);
1919
1920 let mut round_record = ReviewRound {
1921 round,
1922 head: head.clone(),
1923 reviews: records,
1924 e2e,
1925 fix: None,
1926 blocking,
1927 clean,
1928 };
1929
1930 if clean {
1931 self.state.event(
1932 "review",
1933 format!("round {round}: clean — no blocking findings, verification green"),
1934 );
1935 self.state.reviews.push(round_record);
1936 self.state.status = RunStatus::Gating;
1937 self.state.save()?;
1938 return Ok(());
1939 }
1940
1941 if round == max_rounds {
1942 self.state.reviews.push(round_record);
1943 self.state.status = RunStatus::Blocked;
1944 self.state.event(
1945 "review",
1946 format!("{blocking} blocking finding(s) still open after {max_rounds} rounds"),
1947 );
1948 self.state.save()?;
1949 return Ok(());
1950 }
1951
1952 let (fix_spec, fix_seat_key) = match &self.roles.fixer {
1955 Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1956 _ => (
1957 self.state
1958 .config
1959 .agent(&winner.agent)
1960 .cloned()
1961 .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
1962 format!("impl-{}", winner.label),
1963 ),
1964 };
1965 let seat = self.seat(&fix_seat_key, &fix_spec.id);
1966 let blocking_findings: Vec<_> = all_findings
1967 .iter()
1968 .filter(|f| f.severity.blocks())
1969 .cloned()
1970 .collect();
1971 let job = SeatJob {
1972 prompt: prompt::fix(
1973 &self.state.instruction,
1974 &blocking_findings,
1975 (!e2e_failures.is_empty()).then_some(e2e_failures.as_str()),
1976 round,
1977 max_rounds,
1978 &language,
1979 ),
1980 spec: fix_spec.clone(),
1981 seat,
1982 cwd: winner.worktree.clone(),
1983 timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
1984 allow_write: true,
1985 sessions,
1986 artifacts: artifacts.clone(),
1987 stem: format!("fix-{round}"),
1988 };
1989 let before = git::rev_parse(&winner.worktree, "HEAD").await?;
1990 let (seat, out) = run_one(job, Arc::clone(&self.sem), &run_id, "fix", &prompts).await;
1991 let agent_id = seat.agent.clone();
1992 let seat_key = seat.key.clone();
1993 self.state.seats.insert(seat.key.clone(), seat);
1994
1995 let mut fix = FixRecord {
1996 agent: agent_id,
1997 addressed: Vec::new(),
1998 rejected: Vec::new(),
1999 notes: String::new(),
2000 committed: false,
2001 failed: None,
2002 duration_ms: 0,
2003 };
2004 match out {
2005 AgentOutcome::Ok(o) => {
2006 fix.duration_ms = o.duration_ms;
2007 match verdict::extract_json::<FixReport>(&o.text) {
2008 Ok(report) => {
2009 fix.addressed = report.addressed;
2010 fix.rejected = report.rejected;
2011 fix.notes =
2012 blind::sanitize_prose(&report.notes, &self.state.config.blind);
2013 }
2014 Err(e) => fix.failed = Some(format!("unparsable fix report: {e}")),
2015 }
2016 }
2017 AgentOutcome::Dropped(o) => {
2019 fix.duration_ms = o.duration_ms;
2020 let why = o
2021 .dropped
2022 .as_ref()
2023 .map(|d| d.why.as_str())
2024 .unwrap_or("the CLI ended the stream without delivering its answer");
2025 fix.failed = Some(format!("the CLI dropped the stream ({why})"));
2026 }
2027 AgentOutcome::Quota(o) => {
2028 self.state.quota.push(QuotaLoss {
2029 seat: seat_key,
2030 node: "fix".to_owned(),
2031 at: Timestamp::now(),
2032 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2033 });
2034 fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
2035 }
2036 AgentOutcome::Failed(e) => fix.failed = Some(e),
2037 }
2038 git::commit_all(
2039 &winner.worktree,
2040 &format!("magi: review round {round} fixes (uncommitted work)"),
2041 )
2042 .await
2043 .ok();
2044 let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2045 fix.committed = after != before;
2046 self.state.event(
2047 "fix",
2048 format!(
2049 "round {round}: {} addressed, {} rejected, {}",
2050 fix.addressed.len(),
2051 fix.rejected.len(),
2052 if fix.committed {
2053 "committed"
2054 } else {
2055 "NO new commit"
2056 }
2057 ),
2058 );
2059 let stalled = !fix.committed;
2060 round_record.fix = Some(fix);
2061 self.state.reviews.push(round_record);
2062 self.state.save()?;
2063
2064 prev_e2e = (!e2e_failures.is_empty()).then_some(e2e_failures);
2065
2066 if stalled {
2067 self.state.status = RunStatus::Blocked;
2068 self.state.event(
2069 "review",
2070 "the fixer produced no commit; stopping instead of looping on an unchanged tree"
2071 .to_owned(),
2072 );
2073 self.state.save()?;
2074 return Ok(());
2075 }
2076 }
2077 Ok(())
2078 }
2079
2080 async fn gate(&mut self) -> Result<()> {
2083 if self.state.status == RunStatus::Blocked || self.state.status == RunStatus::Failed {
2084 return Ok(());
2085 }
2086 if !self.state.gate.is_empty() {
2087 return Ok(());
2088 }
2089 let Some(winner) = self.state.winner().cloned() else {
2090 return Ok(());
2091 };
2092 self.state.status = RunStatus::Gating;
2093 let shell = self.state.config.shell();
2094 let outcomes = run_commands(
2095 &shell,
2096 &self.state.config.verify.gate,
2097 &winner.worktree,
2098 Duration::from_secs(self.state.config.graph.timeout_review),
2099 )
2100 .await;
2101 for o in &outcomes {
2102 self.state.event(
2103 "gate",
2104 format!(
2105 "`{}` -> {}",
2106 o.command,
2107 if o.ok() {
2108 "pass".to_owned()
2109 } else {
2110 format!("FAIL ({:?})", o.code)
2111 }
2112 ),
2113 );
2114 }
2115 let passed = outcomes.iter().all(CommandOutcome::ok);
2116 self.state.gate = outcomes;
2117 if !passed {
2118 self.state.status = RunStatus::Blocked;
2119 self.state.event("gate", "gate failed; not merging");
2120 }
2121 self.state.save()?;
2122 Ok(())
2123 }
2124
2125 async fn merge(&mut self) -> Result<()> {
2128 if self.state.status.done() && self.state.status != RunStatus::Ready {
2129 return Ok(());
2130 }
2131 let Some(winner) = self.state.winner().cloned() else {
2132 return Ok(());
2133 };
2134 let repo = self.state.repo.clone();
2135 let base = self.state.base_branch.clone();
2136 let mode = self.state.config.merge.mode;
2137 let message = format!(
2138 "Merge magi run {} (candidate {})\n\n{}",
2139 self.state.id, winner.label, self.state.instruction
2140 );
2141
2142 let outcome = match mode {
2143 MergeMode::None => MergeOutcome {
2144 mode,
2145 ok: true,
2146 detail: format!("git -C {} merge --no-ff {}", repo.display(), winner.branch),
2147 },
2148 MergeMode::Local => {
2149 let on = git::current_branch(&repo).await?;
2150 if on.as_deref() != Some(base.as_str()) {
2151 MergeOutcome {
2152 mode,
2153 ok: false,
2154 detail: format!(
2155 "{} has {} checked out, not the base branch {base}",
2156 repo.display(),
2157 on.unwrap_or_else(|| "a detached HEAD".to_owned())
2158 ),
2159 }
2160 } else if !git::is_clean(&repo).await? {
2161 MergeOutcome {
2162 mode,
2163 ok: false,
2164 detail: format!("{} is dirty; refusing to merge", repo.display()),
2165 }
2166 } else {
2167 let out = git::merge_no_ff(&repo, &winner.branch, &message).await?;
2168 MergeOutcome {
2169 mode,
2170 ok: out.ok(),
2171 detail: if out.ok() { out.stdout } else { out.stderr },
2172 }
2173 }
2174 }
2175 MergeMode::Pr => {
2176 let remote = self.state.config.merge.remote.clone();
2177 let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
2178 if !pushed.ok() {
2179 MergeOutcome {
2180 mode,
2181 ok: false,
2182 detail: pushed.stderr,
2183 }
2184 } else {
2185 let out = gh_pr_create(&winner.worktree, &base, &winner.branch, &message).await;
2186 match out {
2187 Ok(url) => MergeOutcome {
2188 mode,
2189 ok: true,
2190 detail: url,
2191 },
2192 Err(e) => MergeOutcome {
2193 mode,
2194 ok: false,
2195 detail: e.to_string(),
2196 },
2197 }
2198 }
2199 }
2200 };
2201
2202 self.state.status = match (mode, outcome.ok) {
2203 (MergeMode::None, _) => RunStatus::Ready,
2204 (_, true) => RunStatus::Merged,
2205 (_, false) => RunStatus::Blocked,
2206 };
2207 self.state.event(
2208 "merge",
2209 format!(
2210 "{:?}: {}",
2211 mode,
2212 outcome.detail.lines().next().unwrap_or("")
2213 ),
2214 );
2215 self.state.merge = Some(outcome);
2216 self.state.save()?;
2217
2218 if self.state.config.graph.land
2224 && mode == MergeMode::Pr
2225 && self.state.status == RunStatus::Merged
2226 {
2227 let url = self
2228 .state
2229 .merge
2230 .as_ref()
2231 .map(|m| m.detail.clone())
2232 .unwrap_or_default();
2233 let url = url.lines().next().unwrap_or("").trim().to_owned();
2234 if url.starts_with("http") {
2235 match land::land(&mut self.state, &url).await {
2238 Ok(pr) => {
2239 self.state.status = match pr.state {
2240 land::PrLifecycle::Merged => RunStatus::Merged,
2241 _ => RunStatus::Blocked,
2242 };
2243 }
2244 Err(e) => {
2245 self.state.status = RunStatus::Blocked;
2246 self.state.event("land", format!("gave up: {e}"));
2247 }
2248 }
2249 self.state.save()?;
2250 }
2251 }
2252 Ok(())
2253 }
2254
2255 fn seat(&mut self, key: &str, agent: &str) -> SeatState {
2259 if let Some(existing) = self.state.seats.get(key)
2260 && existing.agent == agent
2261 {
2262 return existing.clone();
2263 }
2264 let fresh = SeatState::new(key, agent, self.state.seed);
2265 self.state.seats.insert(key.to_owned(), fresh.clone());
2266 fresh
2267 }
2268
2269 fn view(&self, c: &Candidate) -> CandidateView {
2271 let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2272 .unwrap_or_default();
2273 let (patch, _) = blind::sanitize_patch(
2274 &format!("candidate {} patch", c.label),
2275 &raw,
2276 &self.state.config.blind,
2277 );
2278 CandidateView {
2279 label: c.label,
2280 branch: c.branch.clone(),
2281 summary: c.summary.clone(),
2282 stat: c.stat.clone(),
2283 patch,
2284 }
2285 }
2286
2287 fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
2289 let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
2290 prompt::judge(
2291 "(see above)",
2292 &views,
2293 self.roles.judges.len(),
2294 base_short,
2295 "en",
2296 )
2297 }
2298
2299 fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
2306 let mut turns = Vec::new();
2307 for j in &self.state.judgements {
2308 if j.ranking.is_empty() {
2309 continue;
2310 }
2311 let reasons = j
2312 .reasons
2313 .iter()
2314 .map(|(k, v)| format!("- {k}: {v}"))
2315 .collect::<Vec<_>>()
2316 .join("\n");
2317 turns.push(Turn {
2318 who: format!("Judge {} (opening ranking)", j.judge),
2319 is_self: j.judge == self_idx + 1,
2320 body: format!(
2321 "Ranked {}{}{reasons}",
2322 j.ranking.iter().collect::<String>(),
2323 if reasons.is_empty() {
2324 ""
2325 } else {
2326 ", because:\n"
2327 }
2328 ),
2329 });
2330 }
2331 for t in self
2332 .state
2333 .deliberation
2334 .iter()
2335 .flat_map(|r| r.turns.iter())
2336 .chain(current)
2337 {
2338 turns.push(Turn {
2339 who: format!("Judge {}", t.judge),
2340 is_self: t.judge == self_idx + 1,
2341 body: t.body.clone(),
2342 });
2343 }
2344 turns
2345 }
2346}
2347
2348fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
2350 agent::has_session(spec.kind, seat, sessions)
2351}
2352
2353fn short(commit: &str) -> String {
2354 commit.chars().take(7).collect()
2355}
2356
2357fn make_executable(path: &Path) -> Result<()> {
2358 #[cfg(unix)]
2359 {
2360 use std::os::unix::fs::PermissionsExt as _;
2361 let mut perms = std::fs::metadata(path)?.permissions();
2362 perms.set_mode(0o755);
2363 std::fs::set_permissions(path, perms)?;
2364 }
2365 #[cfg(not(unix))]
2366 {
2367 let _ = path;
2368 }
2369 Ok(())
2370}
2371
2372async fn run_one(
2374 job: SeatJob,
2375 sem: Arc<Semaphore>,
2376 run: &str,
2377 node: &str,
2378 prompts: &Prompts,
2379) -> (SeatState, AgentOutcome) {
2380 let (_, seat, out) = wave(vec![job], sem, run, node, prompts)
2381 .await
2382 .pop()
2383 .expect("one job in, one result out");
2384 (seat, out)
2385}
2386
2387async fn wave(
2394 jobs: Vec<SeatJob>,
2395 sem: Arc<Semaphore>,
2396 run: &str,
2397 node: &str,
2398 prompts: &Prompts,
2399) -> Vec<(usize, SeatState, AgentOutcome)> {
2400 let mut set = tokio::task::JoinSet::new();
2401 let overlay = prompts.overlay(node);
2402 for (i, mut job) in jobs.into_iter().enumerate() {
2403 job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
2404 let sem = Arc::clone(&sem);
2405 let run = run.to_owned();
2406 let node = node.to_owned();
2407 set.spawn(async move {
2408 let _permit = sem.acquire().await;
2409 let mut seat = job.seat;
2410 let out = agent::invoke(
2411 &job.spec,
2412 &mut seat,
2413 &Invocation {
2414 cwd: &job.cwd,
2415 prompt: &job.prompt,
2416 timeout: job.timeout,
2417 allow_write: job.allow_write,
2418 sessions: job.sessions,
2419 artifacts: &job.artifacts,
2420 stem: &job.stem,
2421 run: &run,
2422 node: &node,
2423 },
2424 )
2425 .await;
2426 let out = match out {
2427 Ok(o) if o.usable() => AgentOutcome::Ok(o),
2428 Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
2429 Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
2437 Ok(o) if o.timed_out => AgentOutcome::Failed("timed out".to_owned()),
2438 Ok(o) => AgentOutcome::Failed(format!(
2439 "exited with {:?} and no usable output",
2440 o.exit_code
2441 )),
2442 Err(e) => AgentOutcome::Failed(e.to_string()),
2443 };
2444 (i, seat, out)
2445 });
2446 }
2447 let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
2448 while let Some(joined) = set.join_next().await {
2449 let (i, seat, out) = match joined {
2450 Ok(v) => v,
2451 Err(e) => {
2452 tracing::error!("agent task panicked: {e}");
2453 continue;
2454 }
2455 };
2456 if collected.len() <= i {
2457 collected.resize_with(i + 1, || None);
2458 }
2459 collected[i] = Some((i, seat, out));
2460 }
2461 collected.into_iter().flatten().collect()
2462}
2463
2464fn retry_budget(full: Duration, nudged: bool) -> Duration {
2479 if nudged {
2480 (full / 4).max(Duration::from_secs(120)).min(full)
2481 } else {
2482 full
2483 }
2484}
2485
2486#[allow(clippy::too_many_arguments)]
2499async fn ask_json_wave<T>(
2500 jobs: Vec<SeatJob>,
2501 sem: Arc<Semaphore>,
2502 retries: usize,
2503 run: &str,
2504 node: &str,
2505 prompts: &Prompts,
2506 losses: &mut Vec<QuotaLoss>,
2507 validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
2508) -> Vec<(SeatState, Result<(T, AgentOutput)>)>
2509where
2510 T: serde::de::DeserializeOwned + Send + 'static,
2511{
2512 let n = jobs.len();
2513 let originals: Vec<SeatJob> = jobs;
2514 let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
2515 let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
2516 let mut pending: Vec<usize> = (0..n).collect();
2517
2518 for attempt in 0..=retries {
2519 if pending.is_empty() {
2520 break;
2521 }
2522 let mut batch = Vec::with_capacity(pending.len());
2523 for &i in &pending {
2524 let src = &originals[i];
2525 let (prompt, timeout) = if attempt == 0 {
2528 (src.prompt.clone(), src.timeout)
2529 } else {
2530 let why = done[i]
2531 .as_ref()
2532 .and_then(|r| r.as_ref().err().map(ToString::to_string))
2533 .unwrap_or_else(|| "no parsable answer".to_owned());
2534 let nudge = prompt::nudge(&why);
2535 let nudged = has_context(&src.spec, &seats[i], src.sessions);
2536 let prompt = if nudged {
2537 nudge
2538 } else {
2539 format!("{}\n\n---\n\n{}", src.prompt, nudge)
2540 };
2541 (prompt, retry_budget(src.timeout, nudged))
2542 };
2543 batch.push(SeatJob {
2544 spec: src.spec.clone(),
2545 seat: seats[i].clone(),
2546 cwd: src.cwd.clone(),
2547 prompt,
2548 timeout,
2549 allow_write: src.allow_write,
2550 sessions: src.sessions,
2551 artifacts: src.artifacts.clone(),
2552 stem: if attempt == 0 {
2553 src.stem.clone()
2554 } else {
2555 format!("{}-retry{attempt}", src.stem)
2556 },
2557 });
2558 }
2559
2560 let results = wave(batch, Arc::clone(&sem), run, node, prompts).await;
2561 let mut still = Vec::new();
2562 for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
2563 seats[i] = seat;
2564 let (parsed, quota) = match out {
2565 AgentOutcome::Ok(o) => (
2566 match verdict::extract_json::<T>(&o.text) {
2567 Ok(v) => match validate(&v) {
2568 Ok(()) => Ok((v, o)),
2569 Err(e) => Err(e),
2570 },
2571 Err(e) => Err(e),
2572 },
2573 false,
2574 ),
2575 AgentOutcome::Quota(o) => {
2576 losses.push(QuotaLoss {
2577 seat: originals[i].seat.key.clone(),
2578 node: node.to_owned(),
2579 at: Timestamp::now(),
2580 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2581 });
2582 (
2583 Err(anyhow::anyhow!("rate limited (quota); not retrying now")),
2584 true,
2585 )
2586 }
2587 AgentOutcome::Dropped(o) => {
2592 let why = o
2593 .dropped
2594 .as_ref()
2595 .map(|d| d.why.as_str())
2596 .unwrap_or("the CLI ended the stream without delivering its answer");
2597 (
2598 Err(anyhow::anyhow!("the CLI dropped the stream ({why})")),
2599 false,
2600 )
2601 }
2602 AgentOutcome::Failed(e) => (Err(anyhow::anyhow!(e)), false),
2603 };
2604 let failed = parsed.is_err();
2605 done[i] = Some(parsed);
2606 if failed && !quota {
2609 still.push(i);
2610 }
2611 }
2612 pending = still;
2613 }
2614
2615 seats
2616 .into_iter()
2617 .zip(done)
2618 .map(|(seat, res)| {
2619 (
2620 seat,
2621 res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
2622 )
2623 })
2624 .collect()
2625}
2626
2627async fn run_commands(
2629 shell: &[String],
2630 commands: &[String],
2631 cwd: &Path,
2632 timeout: Duration,
2633) -> Vec<CommandOutcome> {
2634 let mut out = Vec::new();
2635 for command in commands {
2636 let started = Instant::now();
2637 let mut cmd = tokio::process::Command::new(&shell[0]);
2638 cmd.quiet();
2639 cmd.args(&shell[1..])
2640 .arg(command)
2641 .current_dir(cwd)
2642 .stdin(std::process::Stdio::null())
2643 .stdout(std::process::Stdio::piped())
2644 .stderr(std::process::Stdio::piped())
2645 .kill_on_drop(true);
2646 let spawned = cmd.spawn();
2647 let (code, body) = match spawned {
2648 Ok(child) => match tokio::time::timeout(timeout, child.wait_with_output()).await {
2649 Ok(Ok(o)) => {
2650 let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
2651 body.push_str(&String::from_utf8_lossy(&o.stderr));
2652 (o.status.code(), body)
2653 }
2654 Ok(Err(e)) => (None, format!("failed to run: {e}")),
2655 Err(_) => (None, format!("timed out after {}s", timeout.as_secs())),
2656 },
2657 Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
2658 };
2659 out.push(CommandOutcome {
2660 command: command.clone(),
2661 code,
2662 output_tail: tail(&body, OUTPUT_TAIL),
2663 duration_ms: started.elapsed().as_millis() as u64,
2664 });
2665 }
2666 out
2667}
2668
2669async fn gh_pr_create(cwd: &Path, base: &str, head: &str, body: &str) -> Result<String> {
2671 let title = body.lines().next().unwrap_or("magi run").to_owned();
2672 let out = tokio::process::Command::new("gh")
2673 .args([
2674 "pr", "create", "--base", base, "--head", head, "--title", &title, "--body", body,
2675 ])
2676 .current_dir(cwd)
2677 .stdin(std::process::Stdio::null())
2678 .output()
2679 .await
2680 .context("spawn gh")?;
2681 if out.status.success() {
2682 Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2683 } else {
2684 bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
2685 }
2686}
2687
2688pub async fn fold_run(state: &mut RunState, drop_winner: bool) -> Result<Vec<String>> {
2690 let repo = state.repo.clone();
2691 let root = state.worktree_root();
2692 let winner = state.tally.as_ref().map(|t| t.winner);
2693 let mut removed = Vec::new();
2694
2695 for i in 0..state.candidates.len() {
2696 let c = state.candidates[i].clone();
2697 let is_winner = Some(c.label) == winner;
2698 if is_winner && !drop_winner {
2699 continue;
2700 }
2701 if c.worktree.exists() {
2702 git::worktree_remove(&repo, &c.worktree).await.ok();
2703 removed.push(c.worktree.to_string_lossy().into_owned());
2704 }
2705 if git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
2706 git::branch_delete(&repo, &c.branch).await.ok();
2707 removed.push(c.branch.clone());
2708 }
2709 state.candidates[i].folded = true;
2710 }
2711
2712 for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
2713 let path = name.path();
2714 let keep = !drop_winner
2715 && winner.is_some_and(|w| {
2716 path.file_name()
2717 .is_some_and(|n| n == format!("cand-{w}").as_str())
2718 });
2719 if keep {
2720 continue;
2721 }
2722 git::worktree_remove(&repo, &path).await.ok();
2723 removed.push(path.to_string_lossy().into_owned());
2724 }
2725
2726 if state.enabled_worktree_config && drop_winner {
2727 git::disable_worktree_config(&repo).await.ok();
2728 state.enabled_worktree_config = false;
2729 }
2730 state.save()?;
2731 Ok(removed)
2732}
2733
2734pub fn worst_open(state: &RunState) -> Option<Severity> {
2736 state
2737 .reviews
2738 .last()?
2739 .reviews
2740 .iter()
2741 .flat_map(|r| r.findings.iter())
2742 .map(|f| f.severity)
2743 .max()
2744}
2745
2746#[cfg(test)]
2747mod tests {
2748 use super::retry_budget;
2749 use std::time::Duration;
2750
2751 fn secs(n: u64) -> Duration {
2752 Duration::from_secs(n)
2753 }
2754
2755 #[test]
2756 fn a_nudge_gets_a_quarter_of_the_budget() {
2757 assert_eq!(retry_budget(secs(1200), true), secs(300));
2759 assert_eq!(retry_budget(secs(3600), true), secs(900));
2760 }
2761
2762 #[test]
2763 fn a_resent_prompt_keeps_the_whole_budget() {
2764 assert_eq!(retry_budget(secs(1200), false), secs(1200));
2767 assert_eq!(retry_budget(secs(60), false), secs(60));
2768 }
2769
2770 #[test]
2771 fn the_floor_never_exceeds_the_original_budget() {
2772 assert_eq!(retry_budget(secs(60), true), secs(60));
2776 assert_eq!(retry_budget(secs(480), true), secs(120));
2777 assert_eq!(retry_budget(secs(0), true), secs(0));
2778 }
2779}