Skip to main content

magi/
graph.rs

1//! The competition graph.
2//!
3//! ```text
4//! prep ──► implement ×N ──► judge ×M (blind) ──► split? ──► deliberate ──► vote (private)
5//!                                                   │                          │
6//!                                                   └──── unanimous ───────────┤
7//!                                                                              ▼
8//!   merge ◄── gate ◄── review ×R + E2E, fix, repeat ◄── fold losers ◄──────── tally
9//! ```
10//!
11//! Every node persists before the next one starts, so a run can be resumed
12//! after a crash, a rate limit, or a reboot without re-spending the work that
13//! already landed.
14//!
15//! The design decision that matters most is *where the facilitator lives*.
16//! There is no moderator agent: magi assigns the labels, decides the
17//! presentation order, relays the transcript, and collects the final votes
18//! one-to-one. A moderator that never learns an author cannot leak one.
19use 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
43/// How much verification output is kept and fed back to the fixer.
44const OUTPUT_TAIL: usize = 8_000;
45
46/// One queued agent invocation.
47///
48/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
49/// CLI hung up on its own stream is asked again from the same job rather than
50/// rebuilt from scratch. See [`Runner::resume_undelivered`].
51#[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
64/// How the graph reads one agent invocation.
65///
66/// Quota is split out from an ordinary failure on purpose: a rate-limited call
67/// is known to fail again if retried now, so the retry loop must not spend an
68/// attempt on it. `Dropped` is split out for the opposite reason: unlike
69/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
70/// error JSON, never the agent's answer — a caller that matched only
71/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
72/// left to read that JSON as if it were usable output. `resume_undelivered`
73/// is the only caller that acts on it; everywhere else it is reported like an
74/// ordinary failure.
75enum AgentOutcome {
76    /// A usable output.
77    Ok(AgentOutput),
78    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
79    Quota(AgentOutput),
80    /// The CLI hung up on its own stream after billed work. See
81    /// [`agent::AgentOutput::work_undelivered`].
82    Dropped(AgentOutput),
83    /// Any other failure: a timeout, a bad exit code, an empty reply.
84    Failed(String),
85}
86
87/// A request to park the run at its next node boundary.
88///
89/// Cloning is how the request travels: the loop keeps one handle and hands a
90/// clone to each [`Runner`], and every clone points at the same flag. There
91/// is no channel because there is nothing to send - the only message is
92/// "park", it is idempotent, and a flag cannot be missed by a receiver that
93/// was not listening yet.
94///
95/// The boundary is what makes this cheap. Every node writes the run's state
96/// before the next one starts, and every node skips what is already recorded:
97/// `prep` returns early once candidates exist, `implement` asks only the seats
98/// with nothing on disk, `judge` returns early once judgements exist. So a
99/// parked run resumes into exactly the node it stopped before, and no agent
100/// work is thrown away. Killing the process mid-node, by contrast, loses
101/// whatever the seats in flight had not yet written - which for an implement
102/// wave is an hour of paid work.
103#[derive(Debug, Clone, Default)]
104pub struct Pause(Arc<AtomicBool>);
105
106impl Pause {
107    /// A pause nobody has asked for yet.
108    #[must_use]
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Ask the run to park at its next node boundary. Idempotent.
114    pub fn park(&self) {
115        self.0.store(true, Ordering::SeqCst);
116    }
117
118    /// Has a park been asked for?
119    #[must_use]
120    pub fn parked(&self) -> bool {
121        self.0.load(Ordering::SeqCst)
122    }
123}
124
125/// Drives one run.
126pub struct Runner {
127    /// Run state; public so the CLI can report on it.
128    pub state: RunState,
129    roles: ResolvedRoles,
130    sem: Arc<Semaphore>,
131    /// Set when someone wants the run parked at its next node boundary.
132    pause: Pause,
133}
134
135/// The commit a run branches from: the base branch as the remote has it.
136///
137/// Two failures this replaces. A run used to branch off `HEAD` and so refused
138/// to start on a dirty tree, which made `magi serve` decline every task for as
139/// long as the operator had work in progress - most of the time. Branching off
140/// the *local* base branch fixed that and introduced a worse one: `land` merges
141/// the winner on GitHub, nothing updates the local ref, and the next run
142/// branches off a base missing everything the previous runs landed. Two tasks
143/// in a row from a phone would have had the second silently re-implementing
144/// against stale code and opening a pull request that reverted the first.
145///
146/// Only refs move here - no checkout, no local branch, no merge - so it is safe
147/// with uncommitted work in the tree. A machine with no network still starts:
148/// the fetch may fail and the local tip is used with a warning, because
149/// refusing to run offline is a worse failure than running against a base the
150/// operator can see for themselves.
151///
152/// One function, called by both entry points. Two answers to "where does a run
153/// branch from" is the kind of drift nobody notices until a diff is wrong.
154async 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    /// Start a fresh run against `repo`.
182    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        // Still worth saying out loud. The operator's uncommitted work is not
200        // part of this run, and someone watching a candidate fail to use a
201        // change they just made deserves to know why.
202        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    /// Open a review-only run against work that already exists on `branch`.
224    ///
225    /// The expensive half of the graph is the implement wave — measured at
226    /// 111 and 134 internal tool-loop turns on this repository, against a
227    /// handful for a judge or a reviewer. The cheap half is worth running on
228    /// hand-written work too, and there was no way to reach it.
229    ///
230    /// No new state and no schema change are needed: a run with **one** viable
231    /// candidate and a tally already decided degrades `execute` to exactly
232    /// review → gate → merge, because `judge` skips a single-candidate field,
233    /// `deliberate` has fewer than two first choices to reconcile, `vote`
234    /// returns early, `tally` is already present and `fold_losers` has no
235    /// losers. Resuming such a run therefore does the right thing as well.
236    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        // The commit subjects are the closest thing to a task statement that
263        // existing work carries, and the reviewers are told as much.
264        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        // An attached worktree, so the fixer's commits land on the branch under
286        // review rather than on a detached head nobody will look at again.
287        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            // Not an agent id on purpose: nothing in the roster wrote this, and
316            // the stats tables must not credit anyone with a win for it.
317            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            // No panel sat, so no quorum applies. Zero judges is the correct
340            // number for work that never competed, and must not be reported as
341            // a collapsed panel.
342            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    /// Reopen an existing run.
365    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    /// Walk the graph to a terminal state, skipping nodes already recorded.
378    pub async fn execute(&mut self) -> Result<()> {
379        // Moving again, so it is no longer parked. Set before the walk rather
380        // than in `resume`, so every way of re-entering the graph clears it
381        // and a card cannot claim a run is waiting to be resumed while the
382        // agents are already working.
383        self.state.parked = false;
384        // A run that already lost its quorum never resumes into the verdict
385        // machinery: `deliberate` and `vote` would otherwise clobber the
386        // stalled marker back to Voting and the run would keep going past a
387        // verdict that is no longer trustworthy. Everything already recorded is
388        // kept, so the run stays resumable (or foldable) for a human to pick up.
389        //
390        // On --resume the run gets one chance to repair itself: the seats a
391        // rate limit took out are re-asked. If their quota has since reset and
392        // the quorum is restored, the run picks up and finishes; otherwise it
393        // stays stale and still-resumable for a later retry. If it does not
394        // recover, the returned status stays `Stalled` and nothing was
395        // clobbered (the recovery only mutates entries for the lost seats).
396        if self.state.status == RunStatus::Stalled {
397            if self.recover_stall().await? {
398                self.finish_after_tally().await?;
399            } else {
400                // Still below quorum: persist the marker and stay resumable.
401                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        // A verdict that lost its quorum is not trustworthy: do not review,
427        // gate, or merge on it. Everything already done is kept, so the run
428        // stays resumable (or foldable); the human can replace the agent that
429        // ran out of quota and pick it up.
430        if self.state.status == RunStatus::Stalled {
431            // Persist the stalled marker now — the normal end-of-execute save
432            // below is below this early return, and without it a resumed run
433            // would reload a pre-tally status and keep going.
434            self.state.save()?;
435            return Ok(());
436        }
437        self.finish_after_tally().await?;
438        Ok(())
439    }
440
441    /// Park here if asked to, recording it in the run's own timeline.
442    ///
443    /// Returns whether the caller should stop walking the graph. The state is
444    /// saved either way by the node that just finished; this adds the event so
445    /// the operator's card says why a run that is neither finished nor moving
446    /// is sitting where it is.
447    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    /// Hand the runner a pause to watch.
464    pub fn on_pause(&mut self, pause: Pause) {
465        self.pause = pause;
466    }
467
468    /// The tail of the graph after a trustworthy tally: fold losers, review,
469    /// gate, merge, and persist.
470    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    // ---------------------------------------------------------------- prep
480
481    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        // The hook is the write-time half of the blindness contract; the
492        // presentation filter in `blind` is the half that cannot be bypassed.
493        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        // A judge cannot tell it is looking at its own patch — the seats keep
546        // separate conversations — but a panel that shares agents with the
547        // field is less independent than it looks, and that is worth saying out
548        // loud once per run rather than leaving it in the config.
549        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    // ----------------------------------------------------------- implement
589
590    async fn implement(&mut self) -> Result<()> {
591        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
592        // agent files with `magi task add` name the run that paid for it. The
593        // prompt overlay is cloned alongside it because the waves borrow it
594        // while `self` is mutably borrowed by the node's own bookkeeping.
595        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        // Kept so a seat whose CLI hung up can be asked again from the same
643        // job: `wave` consumes what it is given.
644        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                // Left un-resumed by `resume_undelivered` (a dirty tree
677                // already rescues the work, or there was no session left to
678                // resume into) — reported like the ordinary failure it is,
679                // never as if `o.text` (the CLI's raw error JSON) were an
680                // answer.
681                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            // Rescue anything the agent edited but never committed: an
710            // uncommitted candidate would silently be an empty one.
711            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            // An agent that failed but still produced a committed change stays
740            // in the running: the patch is what gets judged, not the exit code.
741            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    /// Ask again, once, for work a CLI did and then failed to hand over.
765    ///
766    /// [`agent::dropped_stream`] recognises the one shape observed: an error
767    /// status with an empty response and a usage report showing output tokens,
768    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
769    /// seven minutes and 14,267 output tokens that arrived as an empty
770    /// candidate, because `agy`'s own subscriber fell behind and hung up.
771    ///
772    /// Two conditions, and both matter:
773    ///
774    /// - **Only when the tree is untouched.** Often the agent has already
775    ///   written its files and only the closing message was lost; the rescue
776    ///   commit below picks that up and there is nothing to ask for. Re-asking
777    ///   then would pay for a second implementation of work already on disk.
778    /// - **Once.** A CLI that drops one stream can drop the next, and this
779    ///   node is the most expensive in the graph.
780    ///
781    /// The re-ask is a resume, not a re-run: `has_context` is true because the
782    /// dropped reply still carried its `conversation_id`, so the seat is asked
783    /// to finish what it was doing rather than sent the whole task again. It
784    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
785    /// node's - for the same reason a re-ranked judge does: restating finished
786    /// work is not the work.
787    ///
788    /// Unlike a quota this is worth retrying at all: a rate limit fails the
789    /// same way until it resets, while an abandoned conversation is still
790    /// there to be picked up.
791    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            // Already on disk? Then only the closing message was lost.
807            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            // The re-ask only makes sense as a resume: `resume_after_drop`
819            // says nothing about the task, trusting the seat to still hold it.
820            // Without a session to resume — sessions disabled, or this CLI's
821            // drop shape happened not to carry a session id — that prompt
822            // would open a brand-new conversation with no context at all,
823            // which is worse than leaving this as the ordinary failure it
824            // already is.
825            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        // Scan every candidate patch once the set is complete.
865        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    // --------------------------------------------------------------- judge
922
923    async fn judge(&mut self) -> Result<()> {
924        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
925        // agent files with `magi task add` name the run that paid for it. The
926        // prompt overlay is cloned alongside it because the waves borrow it
927        // while `self` is mutably borrowed by the node's own bookkeeping.
928        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    // ---------------------------------------------------------- deliberate
1048
1049    async fn deliberate(&mut self) -> Result<()> {
1050        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1051        // agent files with `magi task add` name the run that paid for it. The
1052        // prompt overlay is cloned alongside it because the waves borrow it
1053        // while `self` is mutably borrowed by the node's own bookkeeping.
1054        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        // Judges argue in sequence so that a turn can answer the one before it;
1096        // that is the difference between deliberation and three parallel
1097        // monologues.
1098        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                    // Never read the CLI's raw error JSON as this judge's
1147                    // position — skip the seat instead, the same as any other
1148                    // failed turn.
1149                    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    // ---------------------------------------------------------------- vote
1214
1215    async fn vote(&mut self) -> Result<()> {
1216        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1217        // agent files with `magi task add` name the run that paid for it. The
1218        // prompt overlay is cloned alongside it because the waves borrow it
1219        // while `self` is mutably borrowed by the node's own bookkeeping.
1220        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    // --------------------------------------------------------------- tally
1342
1343    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        // A judge whose private vote failed still counted once, in the initial
1357        // ranking; using it beats discarding a whole seat.
1358        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        // Whose verdict is this? A rate-limited seat is absent even if it
1420        // ranked before the limit hit, so presence is measured against the
1421        // recorded losses, not just "did a ranking ever appear".
1422        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        // Strict majority of the configured panel. A bare majority is real
1440        // signal we can act on, while a minority verdict must never stand in
1441        // for a healthy one. A one-candidate run needs no panel at all.
1442        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    // ------------------------------------------------------------- recover
1507
1508    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
1509    /// resumed toward completion once the transient cause clears.
1510    ///
1511    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
1512    /// it toward the quorum, which is exactly the set of seats whose absence
1513    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
1514    /// not depend on which node happened to hit the limit), or an ordinary
1515    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
1516    /// seat is never disturbed.
1517    ///
1518    /// A seat that now answers with a usable ranking is "recovered": its
1519    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
1520    /// `tally` counts it present again), and its vote re-collected. A seat that
1521    /// still fails keeps its loss and stays absent.
1522    ///
1523    /// Returns `true` when the re-tally restores the quorum (the run may proceed
1524    /// to review/gate/merge), `false` when it is still below quorum (the run
1525    /// stays `Stalled`, still resumable for a later retry).
1526    #[allow(clippy::too_many_lines)]
1527    async fn recover_stall(&mut self) -> Result<bool> {
1528        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1529        // agent files with `magi task add` name the run that paid for it. The
1530        // prompt overlay is cloned alongside it because the waves borrow it
1531        // while `self` is mutably borrowed by the node's own bookkeeping.
1532        let run_id = self.state.id.clone();
1533        let prompts = self.state.config.prompts.clone();
1534        // Absent seats = quota-lost at any node, or failed outright. Mirroring
1535        // `tally`'s presence test (rather than the old quota-judge/vote filter)
1536        // is what keeps a non-quota collapse — or a quota loss recorded at the
1537        // deliberate node — from being a permanent dead-end on `--resume`.
1538        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        // Map each absent seat key to its 0-based position in `roles.judges`.
1564        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        // Re-rank the lost seats, one blind prompt each.
1575        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        // Refresh the judgement of every seat that ranked again.
1617        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        // Re-ask the votes of the seats that recovered a ranking.
1642        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        // A seat that ranked again is present even if its re-vote failed —
1716        // `tally` falls back to the initial ranking's first choice — so clear
1717        // its quota loss. Seats that still fail keep theirs and stay absent.
1718        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        // Recompute the verdict from the refreshed panel.
1729        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    // ----------------------------------------------------------------- fold
1740
1741    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        // The judges are finished; their checkouts are pure cost from here.
1759        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    // --------------------------------------------------------------- review
1775
1776    async fn review_loop(&mut self) -> Result<()> {
1777        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1778        // agent files with `magi task add` name the run that paid for it. The
1779        // prompt overlay is cloned alongside it because the waves borrow it
1780        // while `self` is mutably borrowed by the node's own bookkeeping.
1781        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            // Each reviewer gets its own detached checkout of exactly this
1811            // commit: nobody can perturb the winner's tree, and the fixer can
1812            // keep working without racing a reviewer.
1813            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                        // A review-only run has no rankings, so nothing
1835                        // competed for this patch and the reviewer is told so.
1836                        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                            // ids are magi's, never the agent's: the fixer's
1892                            // adoption report is keyed by them.
1893                            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            // Fix. The winner's own implementer seat continues its conversation:
1980            // the competition is over, so context is pure benefit now.
1981            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                // The CLI's raw error JSON is not a fix report to parse.
2053                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    // ----------------------------------------------------------------- gate
2116
2117    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    // ---------------------------------------------------------------- merge
2161
2162    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        // The PR is open and the run would historically stop here, leaving the
2254        // operator to watch checks, feed review comments back to a fixer, and
2255        // merge. That was done by hand six times in one session before this
2256        // existed. Opt-in, because merging is the one irreversible thing magi
2257        // can do to a repository.
2258        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                // A land failure is not a lost run: the work is on a branch and
2271                // the PR is open, which is exactly where a human takes over.
2272                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    // -------------------------------------------------------------- helpers
2291
2292    /// Fetch or create a seat, keeping its conversation across nodes.
2293    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    /// A candidate rendered for judging, with the leak policy applied.
2305    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    /// The full candidate set as prompt text, for seats with no live session.
2323    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    /// Anonymised transcript for judge `self_idx`.
2335    ///
2336    /// The initial rankings are always the opening statements. Seeding them
2337    /// only when no turn had been taken yet meant every judge after the first
2338    /// argued against a single voice instead of against the actual split — the
2339    /// disagreement is the information, so it is always on the table.
2340    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
2383/// Does this seat still hold the context a follow-up prompt would rely on?
2384fn 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
2407/// Run one job, honouring the parallelism budget.
2408async 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
2423/// Run every job concurrently, capped by the semaphore, preserving order.
2424///
2425/// `run` and `node` are attribution, not behaviour: they reach the agent as
2426/// `MAGI_RUN` / `MAGI_NODE` so a task the agent files with `magi task add` can
2427/// name the seat that asked for it. They are cloned per job because each job is
2428/// spawned onto its own task and cannot borrow from this frame.
2429async 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                // Billed work the CLI failed to hand over is not an ordinary
2473                // failure, but its text is the CLI's raw error JSON, not an
2474                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
2475                // read it as one by forgetting to check. `usable()` is always
2476                // false here (dropped implies an empty response), so this has
2477                // to be checked before the catch-all `Failed` below or the
2478                // one shape this exists for is lost with the rest.
2479                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
2507/// How long a re-ask may take, given the budget the first attempt had.
2508///
2509/// A `nudged` retry is a request to restate an answer the seat has already
2510/// worked out: it carries no new work, so it does not deserve the original
2511/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
2512/// 133 seconds while a third sat for over ten minutes on a resumed session
2513/// holding 410 KB of prior output - and because the retry had inherited the
2514/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
2515/// judging round whose other seats were long finished.
2516///
2517/// A quarter of the budget, with a floor so that a deliberately short timeout
2518/// does not collapse to nothing. A retry that re-sends the whole prompt
2519/// (because the seat kept no context) is the original job again, and keeps the
2520/// original budget.
2521fn 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/// Run a wave and parse each reply, re-asking the seats whose reply was
2530/// unusable.
2531///
2532/// The re-ask is a nudge rather than the whole prompt again when the seat still
2533/// holds its conversation, which is the difference between a cheap retry and
2534/// paying for the entire candidate set twice.
2535///
2536/// A seat that hits a rate limit is **not** re-asked: the same call will fail
2537/// the same way until the limit resets, so spending a retry attempt on it is
2538/// pure waste. Its loss is recorded in `losses` and it is returned as a failure
2539/// like any other absent seat — the caller decides whether the panel still has
2540/// a quorum.
2541#[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            // The prompt and the budget are one decision: a nudge restates
2570            // finished work, a re-sent prompt redoes it.
2571            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                // Not a parseable answer, but also not worth a special-cased
2632                // retry here: the nudge loop above already re-asks anything
2633                // that fails to parse, which is exactly what a dropped stream
2634                // needs. Just don't hand its raw error JSON to `extract_json`.
2635                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            // Do not re-ask a rate-limited seat (quota) — a retry is known to
2651            // fail the same way; and never re-ask a seat that already parsed.
2652            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
2671/// Run configured shell commands in `cwd`, in order.
2672async 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
2713/// `gh pr create`, returning the PR url.
2714async 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
2732/// Tear a run's worktrees and branches down.
2733pub 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
2778/// Severity of the worst open finding in the last review round, for reporting.
2779pub 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        // The judge and implement budgets magi ships with.
2802        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        // The seat kept no context, so the retry is the original job again and
2809        // shortening it would only guarantee a second failure.
2810        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        // A short configured timeout must not be *raised* by the floor: the
2817        // operator asked for a bound, and a retry may not outlast the attempt
2818        // it is retrying.
2819        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}