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