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::prompt::{self, CandidateView, Turn};
35use crate::run::{
36    Candidate, CommandOutcome, DeliberationRound, DeliberationTurn, FixRecord, Judgement,
37    MergeOutcome, QuotaLoss, ReviewRecord, ReviewRound, RunState, RunStatus, Tally, VoteRecord,
38    tail, write_artifact,
39};
40use crate::verdict::{self, FinalVote, FixReport, Position, Ranking, Review, Severity};
41
42/// How much verification output is kept and fed back to the fixer.
43const OUTPUT_TAIL: usize = 8_000;
44
45/// One queued agent invocation.
46struct SeatJob {
47    spec: AgentSpec,
48    seat: SeatState,
49    cwd: PathBuf,
50    prompt: String,
51    timeout: Duration,
52    allow_write: bool,
53    sessions: bool,
54    artifacts: PathBuf,
55    stem: String,
56}
57
58/// How the graph reads one agent invocation.
59///
60/// Quota is split out from an ordinary failure on purpose: a rate-limited call
61/// is known to fail again if retried now, so the retry loop must not spend an
62/// attempt on it.
63enum AgentOutcome {
64    /// A usable output.
65    Ok(AgentOutput),
66    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
67    Quota(AgentOutput),
68    /// Any other failure: a timeout, a bad exit code, an empty reply.
69    Failed(String),
70}
71
72/// A request to park the run at its next node boundary.
73///
74/// Cloning is how the request travels: the loop keeps one handle and hands a
75/// clone to each [`Runner`], and every clone points at the same flag. There
76/// is no channel because there is nothing to send - the only message is
77/// "park", it is idempotent, and a flag cannot be missed by a receiver that
78/// was not listening yet.
79///
80/// The boundary is what makes this cheap. Every node writes the run's state
81/// before the next one starts, and every node skips what is already recorded:
82/// `prep` returns early once candidates exist, `implement` asks only the seats
83/// with nothing on disk, `judge` returns early once judgements exist. So a
84/// parked run resumes into exactly the node it stopped before, and no agent
85/// work is thrown away. Killing the process mid-node, by contrast, loses
86/// whatever the seats in flight had not yet written - which for an implement
87/// wave is an hour of paid work.
88#[derive(Debug, Clone, Default)]
89pub struct Pause(Arc<AtomicBool>);
90
91impl Pause {
92    /// A pause nobody has asked for yet.
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Ask the run to park at its next node boundary. Idempotent.
99    pub fn park(&self) {
100        self.0.store(true, Ordering::SeqCst);
101    }
102
103    /// Has a park been asked for?
104    #[must_use]
105    pub fn parked(&self) -> bool {
106        self.0.load(Ordering::SeqCst)
107    }
108}
109
110/// Drives one run.
111pub struct Runner {
112    /// Run state; public so the CLI can report on it.
113    pub state: RunState,
114    roles: ResolvedRoles,
115    sem: Arc<Semaphore>,
116    /// Set when someone wants the run parked at its next node boundary.
117    pause: Pause,
118}
119
120/// The commit a run branches from: the base branch as the remote has it.
121///
122/// Two failures this replaces. A run used to branch off `HEAD` and so refused
123/// to start on a dirty tree, which made `magi serve` decline every task for as
124/// long as the operator had work in progress - most of the time. Branching off
125/// the *local* base branch fixed that and introduced a worse one: `land` merges
126/// the winner on GitHub, nothing updates the local ref, and the next run
127/// branches off a base missing everything the previous runs landed. Two tasks
128/// in a row from a phone would have had the second silently re-implementing
129/// against stale code and opening a pull request that reverted the first.
130///
131/// Only refs move here - no checkout, no local branch, no merge - so it is safe
132/// with uncommitted work in the tree. A machine with no network still starts:
133/// the fetch may fail and the local tip is used with a warning, because
134/// refusing to run offline is a worse failure than running against a base the
135/// operator can see for themselves.
136///
137/// One function, called by both entry points. Two answers to "where does a run
138/// branch from" is the kind of drift nobody notices until a diff is wrong.
139async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
140    let tracking = format!("{remote}/{base_branch}");
141    let fetched = git::fetch(repo, remote, base_branch).await;
142    if let Ok(out) = &fetched
143        && out.ok()
144        && git::rev_exists(repo, &tracking).await
145    {
146        return git::rev_parse(repo, &tracking).await;
147    }
148    let why = match &fetched {
149        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
150        Ok(_) => format!("{remote} has no {base_branch}"),
151        Err(e) => e.to_string(),
152    };
153    tracing::warn!(
154        "could not read {tracking} ({why}); branching off the local \
155         {base_branch} instead, which may be behind"
156    );
157    git::rev_parse(repo, base_branch).await.with_context(|| {
158        format!(
159            "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
160             branch that exists"
161        )
162    })
163}
164
165impl Runner {
166    /// Start a fresh run against `repo`.
167    pub async fn start(repo: &Path, instruction: String, config: Config) -> Result<Self> {
168        let repo = git::toplevel(repo).await?;
169        let missing = agent::missing_programs(&config.agents);
170        if !missing.is_empty() {
171            bail!(
172                "these agent programs are not on PATH: {}. Fix the roster in \
173                 magi.toml or install them.",
174                missing.join(", ")
175            );
176        }
177        let base_branch = match config.merge.base.clone() {
178            Some(b) => b,
179            None => git::current_branch(&repo)
180                .await?
181                .context("HEAD is detached; set [merge] base in magi.toml")?,
182        };
183        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
184        // Still worth saying out loud. The operator's uncommitted work is not
185        // part of this run, and someone watching a candidate fail to use a
186        // change they just made deserves to know why.
187        if !git::is_clean(&repo).await? {
188            tracing::warn!(
189                "{} has uncommitted changes; they are not part of this run, \
190                 which branches off {base_branch} ({})",
191                repo.display(),
192                &base_commit[..base_commit.len().min(8)]
193            );
194        }
195        let roles = config.resolve_roles()?;
196        let max_parallel = config.graph.max_parallel.max(1);
197        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
198        state.event("start", format!("run {} created", state.id));
199        state.save()?;
200        Ok(Self {
201            state,
202            roles,
203            sem: Arc::new(Semaphore::new(max_parallel)),
204            pause: Pause::new(),
205        })
206    }
207
208    /// Open a review-only run against work that already exists on `branch`.
209    ///
210    /// The expensive half of the graph is the implement wave — measured at
211    /// 111 and 134 internal tool-loop turns on this repository, against a
212    /// handful for a judge or a reviewer. The cheap half is worth running on
213    /// hand-written work too, and there was no way to reach it.
214    ///
215    /// No new state and no schema change are needed: a run with **one** viable
216    /// candidate and a tally already decided degrades `execute` to exactly
217    /// review → gate → merge, because `judge` skips a single-candidate field,
218    /// `deliberate` has fewer than two first choices to reconcile, `vote`
219    /// returns early, `tally` is already present and `fold_losers` has no
220    /// losers. Resuming such a run therefore does the right thing as well.
221    pub async fn review(repo: &Path, branch: &str, config: Config) -> Result<Self> {
222        let repo = git::toplevel(repo).await?;
223        let missing = agent::missing_programs(&config.agents);
224        if !missing.is_empty() {
225            bail!(
226                "these agent programs are not on PATH: {}. Fix the roster in \
227                 magi.toml or install them.",
228                missing.join(", ")
229            );
230        }
231        if !git::branch_exists(&repo, branch).await? {
232            bail!("no branch `{branch}` in {}", repo.display());
233        }
234        let base_branch = match config.merge.base.clone() {
235            Some(b) => b,
236            None => git::current_branch(&repo)
237                .await?
238                .context("HEAD is detached; set [merge] base in magi.toml")?,
239        };
240        if base_branch == branch {
241            bail!("`{branch}` is the base branch; there is nothing to review against");
242        }
243        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
244
245        let roles = config.resolve_roles()?;
246        let max_parallel = config.graph.max_parallel.max(1);
247        // The commit subjects are the closest thing to a task statement that
248        // existing work carries, and the reviewers are told as much.
249        let log = git::log_oneline(&repo, &base_commit, branch)
250            .await
251            .unwrap_or_default();
252        let instruction = format!(
253            "Review the work already on branch `{branch}`. There is no task \
254             statement: what the change claims to do is whatever its commits \
255             say.\n\n{}",
256            if log.trim().is_empty() {
257                "(no commit messages)"
258            } else {
259                log.trim()
260            }
261        );
262        let mut state = RunState::new(
263            repo.clone(),
264            base_branch,
265            base_commit.clone(),
266            instruction,
267            config,
268        );
269
270        // An attached worktree, so the fixer's commits land on the branch under
271        // review rather than on a detached head nobody will look at again.
272        let worktree = state.worktree_root().join("under-review");
273        if let Some(parent) = worktree.parent() {
274            tokio::fs::create_dir_all(parent).await.ok();
275        }
276        let path = worktree.to_string_lossy().to_string();
277        git::git(&repo, &["worktree", "add", &path, branch])
278            .await
279            .with_context(|| {
280                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
281            })?;
282
283        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
284            .await
285            .unwrap_or(0);
286        if commits == 0 {
287            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
288        }
289        let files = git::changed_files(&worktree, &base_commit, "HEAD")
290            .await
291            .map(|f| f.len())
292            .unwrap_or(0);
293        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
294            .await
295            .unwrap_or_default();
296
297        state.candidates.push(Candidate {
298            index: 0,
299            label: 'A',
300            // Not an agent id on purpose: nothing in the roster wrote this, and
301            // the stats tables must not credit anyone with a win for it.
302            agent: "(existing branch)".to_owned(),
303            branch: branch.to_owned(),
304            worktree,
305            summary: String::new(),
306            stat,
307            files,
308            commits,
309            empty: false,
310            failed: None,
311            duration_ms: 0,
312            folded: false,
313        });
314        state.tally = Some(Tally {
315            first_choice: BTreeMap::from([('A', 0)]),
316            borda: BTreeMap::new(),
317            winner: 'A',
318            rankings: 0,
319            unanimous_initial: false,
320            deliberated: false,
321            changed_votes: 0,
322            unanimous_final: false,
323            tie_break: Some("review-only run: nothing competed".to_owned()),
324            // No panel sat, so no quorum applies. Zero judges is the correct
325            // number for work that never competed, and must not be reported as
326            // a collapsed panel.
327            judges: 0,
328            present: 0,
329            quorum: 0,
330            met_quorum: true,
331        });
332        state.status = RunStatus::Reviewing;
333        state.event(
334            "start",
335            format!(
336                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
337                state.id
338            ),
339        );
340        state.save()?;
341        Ok(Self {
342            state,
343            roles,
344            sem: Arc::new(Semaphore::new(max_parallel)),
345            pause: Pause::new(),
346        })
347    }
348
349    /// Reopen an existing run.
350    pub fn resume(id: &str) -> Result<Self> {
351        let state = RunState::load(id)?;
352        let roles = state.config.resolve_roles()?;
353        let max_parallel = state.config.graph.max_parallel.max(1);
354        Ok(Self {
355            state,
356            roles,
357            sem: Arc::new(Semaphore::new(max_parallel)),
358            pause: Pause::new(),
359        })
360    }
361
362    /// Walk the graph to a terminal state, skipping nodes already recorded.
363    pub async fn execute(&mut self) -> Result<()> {
364        // Moving again, so it is no longer parked. Set before the walk rather
365        // than in `resume`, so every way of re-entering the graph clears it
366        // and a card cannot claim a run is waiting to be resumed while the
367        // agents are already working.
368        self.state.parked = false;
369        // A run that already lost its quorum never resumes into the verdict
370        // machinery: `deliberate` and `vote` would otherwise clobber the
371        // stalled marker back to Voting and the run would keep going past a
372        // verdict that is no longer trustworthy. Everything already recorded is
373        // kept, so the run stays resumable (or foldable) for a human to pick up.
374        //
375        // On --resume the run gets one chance to repair itself: the seats a
376        // rate limit took out are re-asked. If their quota has since reset and
377        // the quorum is restored, the run picks up and finishes; otherwise it
378        // stays stale and still-resumable for a later retry. If it does not
379        // recover, the returned status stays `Stalled` and nothing was
380        // clobbered (the recovery only mutates entries for the lost seats).
381        if self.state.status == RunStatus::Stalled {
382            if self.recover_stall().await? {
383                self.finish_after_tally().await?;
384            } else {
385                // Still below quorum: persist the marker and stay resumable.
386                self.state.save()?;
387            }
388            return Ok(());
389        }
390        self.prep().await?;
391        if self.park_here()? {
392            return Ok(());
393        }
394        self.implement().await?;
395        if self.park_here()? {
396            return Ok(());
397        }
398        self.judge().await?;
399        if self.park_here()? {
400            return Ok(());
401        }
402        self.deliberate().await?;
403        if self.park_here()? {
404            return Ok(());
405        }
406        self.vote().await?;
407        if self.park_here()? {
408            return Ok(());
409        }
410        self.tally()?;
411        // A verdict that lost its quorum is not trustworthy: do not review,
412        // gate, or merge on it. Everything already done is kept, so the run
413        // stays resumable (or foldable); the human can replace the agent that
414        // ran out of quota and pick it up.
415        if self.state.status == RunStatus::Stalled {
416            // Persist the stalled marker now — the normal end-of-execute save
417            // below is below this early return, and without it a resumed run
418            // would reload a pre-tally status and keep going.
419            self.state.save()?;
420            return Ok(());
421        }
422        self.finish_after_tally().await?;
423        Ok(())
424    }
425
426    /// Park here if asked to, recording it in the run's own timeline.
427    ///
428    /// Returns whether the caller should stop walking the graph. The state is
429    /// saved either way by the node that just finished; this adds the event so
430    /// the operator's card says why a run that is neither finished nor moving
431    /// is sitting where it is.
432    fn park_here(&mut self) -> Result<bool> {
433        if !self.pause.parked() {
434            return Ok(false);
435        }
436        self.state.event(
437            "park",
438            format!(
439                "parked after `{}` — resume to carry on from here",
440                self.state.status.as_str()
441            ),
442        );
443        self.state.parked = true;
444        self.state.save()?;
445        Ok(true)
446    }
447
448    /// Hand the runner a pause to watch.
449    pub fn on_pause(&mut self, pause: Pause) {
450        self.pause = pause;
451    }
452
453    /// The tail of the graph after a trustworthy tally: fold losers, review,
454    /// gate, merge, and persist.
455    async fn finish_after_tally(&mut self) -> Result<()> {
456        self.fold_losers().await?;
457        self.review_loop().await?;
458        self.gate().await?;
459        self.merge().await?;
460        self.state.save()?;
461        Ok(())
462    }
463
464    // ---------------------------------------------------------------- prep
465
466    async fn prep(&mut self) -> Result<()> {
467        if !self.state.candidates.is_empty() {
468            return Ok(());
469        }
470        self.state.status = RunStatus::Prep;
471        let repo = self.state.repo.clone();
472        let base = self.state.base_commit.clone();
473        let root = self.state.worktree_root();
474        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
475
476        // The hook is the write-time half of the blindness contract; the
477        // presentation filter in `blind` is the half that cannot be bypassed.
478        let hooks_dir = self.state.dir().join("hooks");
479        if self.state.config.blind.commit_msg_hook {
480            std::fs::create_dir_all(&hooks_dir)
481                .with_context(|| format!("create {}", hooks_dir.display()))?;
482            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
483            let path = hooks_dir.join("commit-msg");
484            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
485            make_executable(&path)?;
486            if git::enable_worktree_config(&repo).await? {
487                self.state.enabled_worktree_config = true;
488            }
489        }
490
491        for (index, (spec, label)) in self
492            .roles
493            .implementers
494            .clone()
495            .into_iter()
496            .zip(labels)
497            .enumerate()
498        {
499            let branch = self.state.branch_for(label);
500            let worktree = root.join(format!("cand-{label}"));
501            git::worktree_add_branch(&repo, &worktree, &branch, &base).await?;
502            if self.state.config.blind.commit_msg_hook {
503                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
504            }
505            git::local_exclude(&worktree, "/.magi/").await?;
506            self.state.candidates.push(Candidate {
507                index,
508                label,
509                agent: spec.id.clone(),
510                branch,
511                worktree,
512                summary: String::new(),
513                stat: String::new(),
514                files: 0,
515                commits: 0,
516                empty: false,
517                failed: None,
518                duration_ms: 0,
519                folded: false,
520            });
521        }
522
523        for j in 1..=self.roles.judges.len() {
524            let wt = root.join(format!("judge-{j}"));
525            if !wt.exists() {
526                git::worktree_add_detached(&repo, &wt, &base).await?;
527            }
528        }
529
530        // A judge cannot tell it is looking at its own patch — the seats keep
531        // separate conversations — but a panel that shares agents with the
532        // field is less independent than it looks, and that is worth saying out
533        // loud once per run rather than leaving it in the config.
534        let authors: Vec<&str> = self
535            .roles
536            .implementers
537            .iter()
538            .map(|a| a.id.as_str())
539            .collect();
540        let overlap: Vec<String> = self
541            .roles
542            .judges
543            .iter()
544            .enumerate()
545            .filter(|(_, j)| authors.contains(&j.id.as_str()))
546            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
547            .collect();
548        if !overlap.is_empty() {
549            let note = format!(
550                "{} also authored a candidate; blind, but the panel is less \
551                 independent than {} distinct agents would be",
552                overlap.join(", "),
553                self.roles.judges.len()
554            );
555            self.state.event("prep", note);
556        }
557
558        self.state.event(
559            "prep",
560            format!(
561                "{} candidates, {} judges, base {} ({})",
562                self.state.candidates.len(),
563                self.roles.judges.len(),
564                &self.state.base_commit[..7.min(self.state.base_commit.len())],
565                self.state.base_branch
566            ),
567        );
568        self.state.status = RunStatus::Implementing;
569        self.state.save()?;
570        Ok(())
571    }
572
573    // ----------------------------------------------------------- implement
574
575    async fn implement(&mut self) -> Result<()> {
576        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
577        // agent files with `magi task add` name the run that paid for it. The
578        // prompt overlay is cloned alongside it because the waves borrow it
579        // while `self` is mutably borrowed by the node's own bookkeeping.
580        let run_id = self.state.id.clone();
581        let prompts = self.state.config.prompts.clone();
582        let todo: Vec<usize> = self
583            .state
584            .candidates
585            .iter()
586            .enumerate()
587            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
588            .map(|(i, _)| i)
589            .collect();
590        if todo.is_empty() {
591            return self.after_implement();
592        }
593        self.state.status = RunStatus::Implementing;
594
595        let language = self.state.config.graph.language.clone();
596        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
597        let sessions = self.state.config.graph.sessions;
598        let artifacts = agent::artifacts_dir(&self.state.dir());
599
600        let mut jobs = Vec::new();
601        for &i in &todo {
602            let (index, label, worktree) = {
603                let c = &self.state.candidates[i];
604                (c.index, c.label, c.worktree.clone())
605            };
606            let spec = self.roles.implementers[index].clone();
607            let seat_key = format!("impl-{label}");
608            let seat = self.seat(&seat_key, &spec.id);
609            let instruction = self.state.instruction.clone();
610            jobs.push(SeatJob {
611                spec,
612                seat,
613                prompt: prompt::implement(&instruction, &worktree.to_string_lossy(), &language),
614                cwd: worktree,
615                timeout,
616                allow_write: true,
617                sessions,
618                artifacts: artifacts.clone(),
619                stem: format!("impl-{label}"),
620            });
621        }
622
623        self.state.event(
624            "implement",
625            format!("{} candidates in parallel", jobs.len()),
626        );
627        let results = wave(jobs, Arc::clone(&self.sem), &run_id, "implement", &prompts).await;
628
629        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
630            let seat_key = seat.key.clone();
631            self.state.seats.insert(seat.key.clone(), seat);
632            let label = self.state.candidates[i].label;
633            let worktree = self.state.candidates[i].worktree.clone();
634            let base = self.state.base_commit.clone();
635
636            let (summary, duration, failed) = match out {
637                AgentOutcome::Ok(o) => {
638                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
639                    let failed = (!o.usable()).then(|| {
640                        if o.timed_out {
641                            "agent timed out".to_owned()
642                        } else {
643                            format!("agent exited with {:?}", o.exit_code)
644                        }
645                    });
646                    (text, o.duration_ms, failed)
647                }
648                AgentOutcome::Quota(o) => {
649                    self.state.quota.push(QuotaLoss {
650                        seat: seat_key,
651                        node: "implement".to_owned(),
652                        at: Timestamp::now(),
653                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
654                    });
655                    (
656                        String::new(),
657                        o.duration_ms,
658                        Some("rate limited (quota); produced no change".to_owned()),
659                    )
660                }
661                AgentOutcome::Failed(e) => (String::new(), 0, Some(e)),
662            };
663
664            // Rescue anything the agent edited but never committed: an
665            // uncommitted candidate would silently be an empty one.
666            let rescued = git::commit_all(
667                &worktree,
668                &format!("magi: candidate {label} (uncommitted work)"),
669            )
670            .await
671            .unwrap_or(false);
672            let commits = git::commits_ahead(&worktree, &base, "HEAD")
673                .await
674                .unwrap_or(0);
675            let patch = git::diff(&worktree, &base, "HEAD")
676                .await
677                .unwrap_or_default();
678            let stat = git::diff_stat(&worktree, &base, "HEAD")
679                .await
680                .unwrap_or_default();
681            let files = git::changed_files(&worktree, &base, "HEAD")
682                .await
683                .map(|f| f.len())
684                .unwrap_or(0);
685            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
686
687            let c = &mut self.state.candidates[i];
688            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
689            c.stat = stat;
690            c.files = files;
691            c.commits = commits;
692            c.duration_ms = duration;
693            c.empty = commits == 0 || patch.trim().is_empty();
694            // An agent that failed but still produced a committed change stays
695            // in the running: the patch is what gets judged, not the exit code.
696            c.failed = match failed {
697                Some(_) if c.empty => failed,
698                _ => None,
699            };
700            let note = match (&c.failed, c.empty, rescued) {
701                (Some(e), _, _) => format!("candidate {label}: {e}"),
702                (None, true, _) => format!("candidate {label}: no change produced"),
703                (None, false, true) => {
704                    format!(
705                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
706                    )
707                }
708                (None, false, false) => {
709                    format!("candidate {label}: {files} files, {commits} commits")
710                }
711            };
712            self.state.event("implement", note);
713            self.state.save()?;
714        }
715
716        self.after_implement()
717    }
718
719    fn after_implement(&mut self) -> Result<()> {
720        // Scan every candidate patch once the set is complete.
721        if self.state.leaks.is_empty() {
722            let cfg = self.state.config.blind.clone();
723            let mut leaks = Vec::new();
724            for c in &self.state.candidates {
725                let Some(patch) =
726                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
727                else {
728                    continue;
729                };
730                leaks.extend(blind::scan(
731                    &format!("candidate {} patch", c.label),
732                    &patch,
733                    &cfg.vendor_tokens,
734                ));
735            }
736            if !leaks.is_empty() {
737                let summary = leaks
738                    .iter()
739                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
740                    .collect::<Vec<_>>()
741                    .join(", ");
742                match cfg.on_leak {
743                    LeakPolicy::Fail => {
744                        self.state.status = RunStatus::Failed;
745                        self.state
746                            .event("blind", format!("vendor text in a patch: {summary}"));
747                        self.state.leaks = leaks;
748                        self.state.save()?;
749                        bail!(
750                            "blind.on_leak = \"fail\" and vendor text reached a \
751                             judged patch: {summary}"
752                        );
753                    }
754                    LeakPolicy::Redact => self.state.event(
755                        "blind",
756                        format!("redacting vendor text for judging: {summary}"),
757                    ),
758                    LeakPolicy::Warn => self.state.event(
759                        "blind",
760                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
761                    ),
762                }
763                self.state.leaks = leaks;
764            }
765        }
766
767        if self.state.viable().is_empty() {
768            self.state.status = RunStatus::Failed;
769            self.state.save()?;
770            bail!("no candidate produced a change; nothing to judge");
771        }
772        self.state.status = RunStatus::Judging;
773        self.state.save()?;
774        Ok(())
775    }
776
777    // --------------------------------------------------------------- judge
778
779    async fn judge(&mut self) -> Result<()> {
780        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
781        // agent files with `magi task add` name the run that paid for it. The
782        // prompt overlay is cloned alongside it because the waves borrow it
783        // while `self` is mutably borrowed by the node's own bookkeeping.
784        let run_id = self.state.id.clone();
785        let prompts = self.state.config.prompts.clone();
786        if !self.state.judgements.is_empty() {
787            return Ok(());
788        }
789        self.state.status = RunStatus::Judging;
790        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
791        if viable.len() == 1 {
792            self.state.event(
793                "judge",
794                format!(
795                    "only candidate {} produced a change; judging skipped",
796                    viable[0].label
797                ),
798            );
799            self.state.save()?;
800            return Ok(());
801        }
802
803        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
804        let language = self.state.config.graph.language.clone();
805        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
806        let sessions = self.state.config.graph.sessions;
807        let artifacts = agent::artifacts_dir(&self.state.dir());
808        let root = self.state.worktree_root();
809        let base_short = short(&self.state.base_commit);
810
811        let mut jobs = Vec::new();
812        let mut orders = Vec::new();
813        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
814            let order = blind::presentation_order(viable.len(), j, self.state.seed);
815            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
816            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
817            let seat_key = format!("judge-{}", j + 1);
818            let seat = self.seat(&seat_key, &spec.id);
819            jobs.push(SeatJob {
820                prompt: prompt::judge(
821                    &self.state.instruction,
822                    &views,
823                    self.roles.judges.len(),
824                    &base_short,
825                    &language,
826                ),
827                spec,
828                seat,
829                cwd: root.join(format!("judge-{}", j + 1)),
830                timeout,
831                allow_write: false,
832                sessions,
833                artifacts: artifacts.clone(),
834                stem: format!("judge-{}", j + 1),
835            });
836        }
837
838        self.state.event(
839            "judge",
840            format!(
841                "{} judges ranking {} candidates blind",
842                jobs.len(),
843                viable.len()
844            ),
845        );
846        let labels_for_check = labels.clone();
847        let mut quota_losses = Vec::new();
848        let results = ask_json_wave::<Ranking>(
849            jobs,
850            Arc::clone(&self.sem),
851            self.state.config.graph.retries,
852            &run_id,
853            "judge",
854            &prompts,
855            &mut quota_losses,
856            &move |r: &Ranking| r.validate(&labels_for_check),
857        )
858        .await;
859        self.state.quota.extend(quota_losses);
860
861        for (j, (seat, res)) in results.into_iter().enumerate() {
862            let agent_id = seat.agent.clone();
863            self.state.seats.insert(seat.key.clone(), seat);
864            let mut record = Judgement {
865                judge: j + 1,
866                seat: format!("judge-{}", j + 1),
867                agent: agent_id,
868                ranking: Vec::new(),
869                reasons: BTreeMap::new(),
870                confidence: None,
871                order: orders[j].clone(),
872                failed: None,
873                duration_ms: 0,
874            };
875            match res {
876                Ok((ranking, out)) => {
877                    record.ranking = ranking.normalized();
878                    record.reasons = ranking.reasons;
879                    record.confidence = ranking.confidence;
880                    record.duration_ms = out.duration_ms;
881                    self.state.event(
882                        "judge",
883                        format!(
884                            "judge {} ranked {}",
885                            j + 1,
886                            record.ranking.iter().collect::<String>()
887                        ),
888                    );
889                }
890                Err(e) => {
891                    record.failed = Some(e.to_string());
892                    self.state
893                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
894                }
895            }
896            self.state.judgements.push(record);
897            self.state.save()?;
898        }
899        Ok(())
900    }
901
902    // ---------------------------------------------------------- deliberate
903
904    async fn deliberate(&mut self) -> Result<()> {
905        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
906        // agent files with `magi task add` name the run that paid for it. The
907        // prompt overlay is cloned alongside it because the waves borrow it
908        // while `self` is mutably borrowed by the node's own bookkeeping.
909        let run_id = self.state.id.clone();
910        let prompts = self.state.config.prompts.clone();
911        if !self.state.deliberation.is_empty() {
912            return Ok(());
913        }
914        let tops: Vec<char> = self
915            .state
916            .judgements
917            .iter()
918            .filter_map(|j| j.ranking.first().copied())
919            .collect();
920        let rounds = self.state.config.graph.deliberate_rounds;
921        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
922            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
923                self.state.event(
924                    "deliberate",
925                    format!("judges agreed on {} outright; no deliberation", tops[0]),
926                );
927            }
928            self.state.status = RunStatus::Voting;
929            self.state.save()?;
930            return Ok(());
931        }
932
933        self.state.status = RunStatus::Deliberating;
934        self.state.event(
935            "deliberate",
936            format!(
937                "split: first choices were {} — opening {rounds} round(s)",
938                tops.iter().collect::<String>()
939            ),
940        );
941
942        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
943        let language = self.state.config.graph.language.clone();
944        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
945        let sessions = self.state.config.graph.sessions;
946        let artifacts = agent::artifacts_dir(&self.state.dir());
947        let root = self.state.worktree_root();
948        let base_short = short(&self.state.base_commit);
949
950        // Judges argue in sequence so that a turn can answer the one before it;
951        // that is the difference between deliberation and three parallel
952        // monologues.
953        for round in 1..=rounds {
954            let mut turns: Vec<DeliberationTurn> = Vec::new();
955            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
956                if self.state.judgements[j].failed.is_some() {
957                    continue;
958                }
959                let seat_key = format!("judge-{}", j + 1);
960                let mut seat = self.seat(&seat_key, &spec.id);
961                let transcript = self.transcript(&turns, j);
962                let context = if has_context(&spec, &seat, sessions) {
963                    None
964                } else {
965                    Some(self.candidate_block(&viable, &base_short))
966                };
967                let text = prompt::deliberate(
968                    &self.state.instruction,
969                    context.as_deref(),
970                    &transcript,
971                    round,
972                    rounds,
973                    &language,
974                );
975                let job = SeatJob {
976                    spec,
977                    seat: seat.clone(),
978                    prompt: text,
979                    cwd: root.join(format!("judge-{}", j + 1)),
980                    timeout,
981                    allow_write: false,
982                    sessions,
983                    artifacts: artifacts.clone(),
984                    stem: format!("delib-{round}-judge-{}", j + 1),
985                };
986                let (updated, out) =
987                    run_one(job, Arc::clone(&self.sem), &run_id, "deliberate", &prompts).await;
988                seat = updated;
989                let agent_id = seat.agent.clone();
990                let seat_key = seat.key.clone();
991                self.state.seats.insert(seat.key.clone(), seat);
992                let body = match out {
993                    AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
994                    AgentOutcome::Quota(o) => {
995                        self.state.quota.push(QuotaLoss {
996                            seat: seat_key,
997                            node: "deliberate".to_owned(),
998                            at: Timestamp::now(),
999                            reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1000                        });
1001                        self.state.event(
1002                            "deliberate",
1003                            format!("judge {} skipped: rate limited (quota)", j + 1),
1004                        );
1005                        continue;
1006                    }
1007                    AgentOutcome::Failed(e) => {
1008                        self.state
1009                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
1010                        continue;
1011                    }
1012                };
1013                let tentative = verdict::extract_json::<Position>(&body)
1014                    .ok()
1015                    .and_then(|p| p.tentative)
1016                    .and_then(|s| s.trim().chars().next())
1017                    .map(|c| c.to_ascii_uppercase());
1018                self.state.event(
1019                    "deliberate",
1020                    format!(
1021                        "round {round}: judge {} now favours {}",
1022                        j + 1,
1023                        tentative.map_or("—".to_owned(), |c| c.to_string())
1024                    ),
1025                );
1026                turns.push(DeliberationTurn {
1027                    judge: j + 1,
1028                    agent: agent_id,
1029                    body: blind::sanitize_prose(&body, &self.state.config.blind),
1030                    tentative,
1031                });
1032            }
1033            self.state
1034                .deliberation
1035                .push(DeliberationRound { round, turns });
1036            self.state.save()?;
1037        }
1038
1039        self.state.status = RunStatus::Voting;
1040        self.state.save()?;
1041        Ok(())
1042    }
1043
1044    // ---------------------------------------------------------------- vote
1045
1046    async fn vote(&mut self) -> Result<()> {
1047        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1048        // agent files with `magi task add` name the run that paid for it. The
1049        // prompt overlay is cloned alongside it because the waves borrow it
1050        // while `self` is mutably borrowed by the node's own bookkeeping.
1051        let run_id = self.state.id.clone();
1052        let prompts = self.state.config.prompts.clone();
1053        if !self.state.votes.is_empty() {
1054            return Ok(());
1055        }
1056        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1057        if viable.len() == 1 {
1058            return Ok(());
1059        }
1060        self.state.status = RunStatus::Voting;
1061
1062        let language = self.state.config.graph.language.clone();
1063        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1064        let sessions = self.state.config.graph.sessions;
1065        let artifacts = agent::artifacts_dir(&self.state.dir());
1066        let root = self.state.worktree_root();
1067        let base_short = short(&self.state.base_commit);
1068        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1069
1070        let mut jobs = Vec::new();
1071        let mut seats_at = Vec::new();
1072        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1073            if self
1074                .state
1075                .judgements
1076                .get(j)
1077                .is_some_and(|r| r.failed.is_some())
1078            {
1079                continue;
1080            }
1081            let seat_key = format!("judge-{}", j + 1);
1082            let seat = self.seat(&seat_key, &spec.id);
1083            let mut text = prompt::final_vote(&viable, &language);
1084            if !has_context(&spec, &seat, sessions) {
1085                text = format!(
1086                    "{}\n\n# Candidates\n\n{}",
1087                    text,
1088                    self.candidate_block(&candidates, &base_short)
1089                );
1090            }
1091            jobs.push(SeatJob {
1092                spec,
1093                seat,
1094                prompt: text,
1095                cwd: root.join(format!("judge-{}", j + 1)),
1096                timeout,
1097                allow_write: false,
1098                sessions,
1099                artifacts: artifacts.clone(),
1100                stem: format!("vote-judge-{}", j + 1),
1101            });
1102            seats_at.push(j);
1103        }
1104
1105        self.state.event(
1106            "vote",
1107            format!(
1108                "collecting {} final votes one by one, privately",
1109                jobs.len()
1110            ),
1111        );
1112        let allowed = viable.clone();
1113        let mut quota_losses = Vec::new();
1114        let results = ask_json_wave::<FinalVote>(
1115            jobs,
1116            Arc::clone(&self.sem),
1117            self.state.config.graph.retries,
1118            &run_id,
1119            "vote",
1120            &prompts,
1121            &mut quota_losses,
1122            &move |v: &FinalVote| match v.label() {
1123                Some(c) if allowed.contains(&c) => Ok(()),
1124                other => bail!("vote {other:?} is not one of {allowed:?}"),
1125            },
1126        )
1127        .await;
1128        self.state.quota.extend(quota_losses);
1129
1130        for (&j, (seat, res)) in seats_at.iter().zip(results) {
1131            let agent_id = seat.agent.clone();
1132            self.state.seats.insert(seat.key.clone(), seat);
1133            let initial = self
1134                .state
1135                .judgements
1136                .get(j)
1137                .and_then(|r| r.ranking.first().copied());
1138            let mut record = VoteRecord {
1139                judge: j + 1,
1140                agent: agent_id,
1141                vote: None,
1142                reason: String::new(),
1143                changed: false,
1144            };
1145            match res {
1146                Ok((v, _)) => {
1147                    record.vote = v.label();
1148                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1149                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
1150                    self.state.event(
1151                        "vote",
1152                        format!(
1153                            "judge {} voted {}{}",
1154                            j + 1,
1155                            record.vote.unwrap_or('?'),
1156                            if record.changed { " (changed)" } else { "" }
1157                        ),
1158                    );
1159                }
1160                Err(e) => {
1161                    self.state
1162                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
1163                }
1164            }
1165            self.state.votes.push(record);
1166            self.state.save()?;
1167        }
1168        Ok(())
1169    }
1170
1171    // --------------------------------------------------------------- tally
1172
1173    fn tally(&mut self) -> Result<()> {
1174        if self.state.tally.is_some() {
1175            return Ok(());
1176        }
1177        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1178        let tops: Vec<char> = self
1179            .state
1180            .judgements
1181            .iter()
1182            .filter_map(|j| j.ranking.first().copied())
1183            .collect();
1184        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
1185
1186        // A judge whose private vote failed still counted once, in the initial
1187        // ranking; using it beats discarding a whole seat.
1188        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1189        let mut cast: Vec<char> = Vec::new();
1190        for (i, j) in self.state.judgements.iter().enumerate() {
1191            let vote = self
1192                .state
1193                .votes
1194                .iter()
1195                .find(|v| v.judge == i + 1)
1196                .and_then(|v| v.vote)
1197                .or_else(|| j.ranking.first().copied());
1198            if let Some(v) = vote {
1199                *first_choice.entry(v).or_insert(0) += 1;
1200                cast.push(v);
1201            }
1202        }
1203
1204        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1205        for j in &self.state.judgements {
1206            let n = j.ranking.len();
1207            for (pos, label) in j.ranking.iter().enumerate() {
1208                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
1209            }
1210        }
1211
1212        let best = first_choice.values().copied().max().unwrap_or(0);
1213        let mut leaders: Vec<char> = first_choice
1214            .iter()
1215            .filter(|(_, v)| **v == best)
1216            .map(|(k, _)| *k)
1217            .collect();
1218        let mut tie_break = None;
1219        if leaders.len() > 1 {
1220            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
1221            let borda_leaders: Vec<char> = leaders
1222                .iter()
1223                .copied()
1224                .filter(|l| borda[l] == top_borda)
1225                .collect();
1226            tie_break = Some(if borda_leaders.len() == 1 {
1227                format!(
1228                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
1229                    leaders.len()
1230                )
1231            } else {
1232                format!(
1233                    "{} way tie on both first-choice votes and Borda points, broken by label order",
1234                    leaders.len()
1235                )
1236            });
1237            leaders = borda_leaders;
1238            leaders.sort_unstable();
1239        }
1240        let winner = *leaders
1241            .first()
1242            .or(viable.first())
1243            .context("no candidate to declare a winner from")?;
1244
1245        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
1246        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
1247        let deliberated = !self.state.deliberation.is_empty();
1248
1249        // Whose verdict is this? A rate-limited seat is absent even if it
1250        // ranked before the limit hit, so presence is measured against the
1251        // recorded losses, not just "did a ranking ever appear".
1252        let quota_seats: std::collections::BTreeSet<&str> =
1253            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1254        let mut present = 0usize;
1255        for (i, j) in self.state.judgements.iter().enumerate() {
1256            if quota_seats.contains(j.seat.as_str()) {
1257                continue;
1258            }
1259            let ranked = !j.ranking.is_empty() && j.failed.is_none();
1260            let voted = self
1261                .state
1262                .votes
1263                .iter()
1264                .any(|v| v.judge == i + 1 && v.vote.is_some());
1265            if ranked || voted {
1266                present += 1;
1267            }
1268        }
1269        // Strict majority of the configured panel. A bare majority is real
1270        // signal we can act on, while a minority verdict must never stand in
1271        // for a healthy one. A one-candidate run needs no panel at all.
1272        let judges_total = self.roles.judges.len();
1273        let needs_quorum = viable.len() > 1;
1274        let quorum = if needs_quorum {
1275            judges_total / 2 + 1
1276        } else {
1277            0
1278        };
1279        let met_quorum = !needs_quorum || present >= quorum;
1280
1281        self.state.event(
1282            "tally",
1283            format!(
1284                "winner {winner} — votes {} | initial {} | {} changed | {present}/{judges_total} judges{}",
1285                first_choice
1286                    .iter()
1287                    .map(|(k, v)| format!("{k}:{v}"))
1288                    .collect::<Vec<_>>()
1289                    .join(" "),
1290                if unanimous_initial {
1291                    "unanimous"
1292                } else {
1293                    "split"
1294                },
1295                changed_votes,
1296                if met_quorum {
1297                    String::new()
1298                } else {
1299                    format!(" — below quorum ({quorum} required)")
1300                },
1301            ),
1302        );
1303        if !met_quorum {
1304            self.state.event(
1305                "stall",
1306                format!(
1307                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
1308                     the run stops here, resumable"
1309                ),
1310            );
1311        }
1312        self.state.tally = Some(Tally {
1313            first_choice,
1314            borda,
1315            winner,
1316            rankings: tops.len(),
1317            unanimous_initial,
1318            deliberated,
1319            changed_votes,
1320            unanimous_final,
1321            tie_break,
1322            judges: judges_total,
1323            present,
1324            quorum,
1325            met_quorum,
1326        });
1327        self.state.status = if met_quorum {
1328            RunStatus::Reviewing
1329        } else {
1330            RunStatus::Stalled
1331        };
1332        self.state.save()?;
1333        Ok(())
1334    }
1335
1336    // ------------------------------------------------------------- recover
1337
1338    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
1339    /// resumed toward completion once the transient cause clears.
1340    ///
1341    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
1342    /// it toward the quorum, which is exactly the set of seats whose absence
1343    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
1344    /// not depend on which node happened to hit the limit), or an ordinary
1345    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
1346    /// seat is never disturbed.
1347    ///
1348    /// A seat that now answers with a usable ranking is "recovered": its
1349    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
1350    /// `tally` counts it present again), and its vote re-collected. A seat that
1351    /// still fails keeps its loss and stays absent.
1352    ///
1353    /// Returns `true` when the re-tally restores the quorum (the run may proceed
1354    /// to review/gate/merge), `false` when it is still below quorum (the run
1355    /// stays `Stalled`, still resumable for a later retry).
1356    #[allow(clippy::too_many_lines)]
1357    async fn recover_stall(&mut self) -> Result<bool> {
1358        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1359        // agent files with `magi task add` name the run that paid for it. The
1360        // prompt overlay is cloned alongside it because the waves borrow it
1361        // while `self` is mutably borrowed by the node's own bookkeeping.
1362        let run_id = self.state.id.clone();
1363        let prompts = self.state.config.prompts.clone();
1364        // Absent seats = quota-lost at any node, or failed outright. Mirroring
1365        // `tally`'s presence test (rather than the old quota-judge/vote filter)
1366        // is what keeps a non-quota collapse — or a quota loss recorded at the
1367        // deliberate node — from being a permanent dead-end on `--resume`.
1368        let quota_seats: BTreeSet<&str> =
1369            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1370        let absent: Vec<String> = self
1371            .state
1372            .judgements
1373            .iter()
1374            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
1375            .map(|j| j.seat.clone())
1376            .collect();
1377        if absent.is_empty() {
1378            return Ok(false);
1379        }
1380        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1381        if viable.len() <= 1 {
1382            return Ok(false);
1383        }
1384        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1385        let language = self.state.config.graph.language.clone();
1386        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1387        let sessions = self.state.config.graph.sessions;
1388        let artifacts = agent::artifacts_dir(&self.state.dir());
1389        let root = self.state.worktree_root();
1390        let base_short = short(&self.state.base_commit);
1391        let candidates: Vec<Candidate> = viable.clone();
1392
1393        // Map each absent seat key to its 0-based position in `roles.judges`.
1394        let mut positions: Vec<usize> = absent
1395            .iter()
1396            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
1397            .collect();
1398        if positions.is_empty() {
1399            return Ok(false);
1400        }
1401        positions.sort_unstable();
1402        positions.dedup();
1403
1404        // Re-rank the lost seats, one blind prompt each.
1405        let mut judge_jobs = Vec::new();
1406        for &j in &positions {
1407            let order = blind::presentation_order(viable.len(), j, self.state.seed);
1408            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1409            let seat_key = format!("judge-{}", j + 1);
1410            let spec = self.roles.judges[j].clone();
1411            let seat = self.seat(&seat_key, &spec.id);
1412            judge_jobs.push(SeatJob {
1413                spec,
1414                seat,
1415                prompt: prompt::judge(
1416                    &self.state.instruction,
1417                    &views,
1418                    self.roles.judges.len(),
1419                    &base_short,
1420                    &language,
1421                ),
1422                cwd: root.join(seat_key),
1423                timeout,
1424                allow_write: false,
1425                sessions,
1426                artifacts: artifacts.clone(),
1427                stem: format!("judge-{}-recover", j + 1),
1428            });
1429        }
1430
1431        let labels_for_check = labels.clone();
1432        let mut judge_losses = Vec::new();
1433        let results = ask_json_wave::<Ranking>(
1434            judge_jobs,
1435            Arc::clone(&self.sem),
1436            self.state.config.graph.retries,
1437            &run_id,
1438            "judge",
1439            &prompts,
1440            &mut judge_losses,
1441            &move |r: &Ranking| r.validate(&labels_for_check),
1442        )
1443        .await;
1444
1445        // Refresh the judgement of every seat that ranked again.
1446        let mut recovered: BTreeSet<usize> = BTreeSet::new();
1447        for (&j, (seat, res)) in positions.iter().zip(results) {
1448            self.state.seats.insert(seat.key.clone(), seat);
1449            let record = &mut self.state.judgements[j];
1450            match res {
1451                Ok((ranking, out)) => {
1452                    record.ranking = ranking.normalized();
1453                    record.reasons = ranking.reasons;
1454                    record.confidence = ranking.confidence;
1455                    record.failed = None;
1456                    record.duration_ms = out.duration_ms;
1457                    recovered.insert(j);
1458                    self.state.event(
1459                        "recover",
1460                        format!("judge {} ranked again after the limit", j + 1),
1461                    );
1462                }
1463                Err(e) => {
1464                    self.state
1465                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
1466                }
1467            }
1468        }
1469
1470        // Re-ask the votes of the seats that recovered a ranking.
1471        let mut vote_jobs = Vec::new();
1472        let mut vote_pos: Vec<usize> = Vec::new();
1473        for &j in &recovered {
1474            let seat_key = format!("judge-{}", j + 1);
1475            let spec = self.roles.judges[j].clone();
1476            let seat = self.seat(&seat_key, &spec.id);
1477            let mut text = prompt::final_vote(&labels, &language);
1478            if !has_context(&spec, &seat, sessions) {
1479                text = format!(
1480                    "{}\n\n# Candidates\n\n{}",
1481                    text,
1482                    self.candidate_block(&candidates, &base_short)
1483                );
1484            }
1485            vote_jobs.push(SeatJob {
1486                spec,
1487                seat,
1488                prompt: text,
1489                cwd: root.join(seat_key),
1490                timeout,
1491                allow_write: false,
1492                sessions,
1493                artifacts: artifacts.clone(),
1494                stem: format!("vote-judge-{}-recover", j + 1),
1495            });
1496            vote_pos.push(j);
1497        }
1498        let allowed = labels.clone();
1499        let mut vote_losses = Vec::new();
1500        let votes = ask_json_wave::<FinalVote>(
1501            vote_jobs,
1502            Arc::clone(&self.sem),
1503            self.state.config.graph.retries,
1504            &run_id,
1505            "vote",
1506            &prompts,
1507            &mut vote_losses,
1508            &move |v: &FinalVote| match v.label() {
1509                Some(c) if allowed.contains(&c) => Ok(()),
1510                other => bail!("vote {other:?} is not one of {allowed:?}"),
1511            },
1512        )
1513        .await;
1514        for (&j, (seat, res)) in vote_pos.iter().zip(votes) {
1515            let agent_id = seat.agent.clone();
1516            self.state.seats.insert(seat.key.clone(), seat);
1517            match res {
1518                Ok((v, _)) => {
1519                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
1520                        rec.vote = v.label();
1521                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1522                    } else {
1523                        self.state.votes.push(VoteRecord {
1524                            judge: j + 1,
1525                            agent: agent_id,
1526                            vote: v.label(),
1527                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
1528                            changed: false,
1529                        });
1530                    }
1531                    self.state.event(
1532                        "recover",
1533                        format!("judge {} voted again after the limit", j + 1),
1534                    );
1535                }
1536                Err(e) => {
1537                    self.state
1538                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
1539                }
1540            }
1541        }
1542
1543        // A seat that ranked again is present even if its re-vote failed —
1544        // `tally` falls back to the initial ranking's first choice — so clear
1545        // its quota loss. Seats that still fail keep theirs and stay absent.
1546        if !recovered.is_empty() {
1547            let recovered_keys: BTreeSet<String> = recovered
1548                .iter()
1549                .map(|&j| format!("judge-{}", j + 1))
1550                .collect();
1551            self.state
1552                .quota
1553                .retain(|q| !recovered_keys.contains(&q.seat));
1554        }
1555
1556        // Recompute the verdict from the refreshed panel.
1557        self.state.tally = None;
1558        self.tally()?;
1559        Ok(self
1560            .state
1561            .tally
1562            .as_ref()
1563            .map(|t| t.met_quorum)
1564            .unwrap_or(false))
1565    }
1566
1567    // ----------------------------------------------------------------- fold
1568
1569    async fn fold_losers(&mut self) -> Result<()> {
1570        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
1571            return Ok(());
1572        };
1573        let repo = self.state.repo.clone();
1574        let mut folded = Vec::new();
1575        for i in 0..self.state.candidates.len() {
1576            let c = &self.state.candidates[i];
1577            if c.label == winner || c.folded {
1578                continue;
1579            }
1580            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
1581            git::worktree_remove(&repo, &wt).await.ok();
1582            git::branch_delete(&repo, &branch).await.ok();
1583            self.state.candidates[i].folded = true;
1584            folded.push(label.to_string());
1585        }
1586        // The judges are finished; their checkouts are pure cost from here.
1587        let root = self.state.worktree_root();
1588        for j in 1..=self.roles.judges.len() {
1589            let wt = root.join(format!("judge-{j}"));
1590            if wt.exists() {
1591                git::worktree_remove(&repo, &wt).await.ok();
1592            }
1593        }
1594        if !folded.is_empty() {
1595            self.state
1596                .event("fold", format!("folded candidates {}", folded.join(", ")));
1597            self.state.save()?;
1598        }
1599        Ok(())
1600    }
1601
1602    // --------------------------------------------------------------- review
1603
1604    async fn review_loop(&mut self) -> Result<()> {
1605        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1606        // agent files with `magi task add` name the run that paid for it. The
1607        // prompt overlay is cloned alongside it because the waves borrow it
1608        // while `self` is mutably borrowed by the node's own bookkeeping.
1609        let run_id = self.state.id.clone();
1610        let prompts = self.state.config.prompts.clone();
1611        let Some(winner) = self.state.winner().cloned() else {
1612            return Ok(());
1613        };
1614        let max_rounds = self.state.config.graph.review_rounds;
1615        if max_rounds == 0 || self.state.reviews.iter().any(|r| r.clean) {
1616            self.state.status = RunStatus::Gating;
1617            self.state.save()?;
1618            return Ok(());
1619        }
1620        self.state.status = RunStatus::Reviewing;
1621
1622        let repo = self.state.repo.clone();
1623        let root = self.state.worktree_root();
1624        let language = self.state.config.graph.language.clone();
1625        let sessions = self.state.config.graph.sessions;
1626        let artifacts = agent::artifacts_dir(&self.state.dir());
1627        let base_short = short(&self.state.base_commit);
1628        let reviewers = self.roles.reviewers.clone();
1629        let base = self.state.base_commit.clone();
1630        let shell = self.state.config.shell();
1631
1632        let mut prev_e2e: Option<String> = None;
1633        for round in (self.state.reviews.len() + 1)..=max_rounds {
1634            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
1635            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
1636            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
1637
1638            // Each reviewer gets its own detached checkout of exactly this
1639            // commit: nobody can perturb the winner's tree, and the fixer can
1640            // keep working without racing a reviewer.
1641            let mut jobs = Vec::new();
1642            for (r, spec) in reviewers.iter().cloned().enumerate() {
1643                let wt = root.join(format!("review-{}", r + 1));
1644                if wt.exists() {
1645                    git::reset_detached(&wt, &head).await?;
1646                } else {
1647                    git::worktree_add_detached(&repo, &wt, &head).await?;
1648                }
1649                let seat_key = format!("review-{}", r + 1);
1650                let seat = self.seat(&seat_key, &spec.id);
1651                jobs.push(SeatJob {
1652                    prompt: prompt::review(&prompt::ReviewCtx {
1653                        instruction: &self.state.instruction,
1654                        branch: &winner.branch,
1655                        base_short: &base_short,
1656                        stat: &stat,
1657                        patch: &patch,
1658                        e2e: prev_e2e.as_deref(),
1659                        reviewers: reviewers.len(),
1660                        round,
1661                        rounds: max_rounds,
1662                        // A review-only run has no rankings, so nothing
1663                        // competed for this patch and the reviewer is told so.
1664                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
1665                        language: &language,
1666                    }),
1667                    spec,
1668                    seat,
1669                    cwd: wt,
1670                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
1671                    allow_write: false,
1672                    sessions,
1673                    artifacts: artifacts.clone(),
1674                    stem: format!("review-{round}-{}", r + 1),
1675                });
1676            }
1677
1678            self.state.event(
1679                "review",
1680                format!(
1681                    "round {round}: {} reviewers on {}",
1682                    jobs.len(),
1683                    short(&head)
1684                ),
1685            );
1686            let mut quota_losses = Vec::new();
1687            let results = ask_json_wave::<Review>(
1688                jobs,
1689                Arc::clone(&self.sem),
1690                self.state.config.graph.retries,
1691                &run_id,
1692                "review",
1693                &prompts,
1694                &mut quota_losses,
1695                &|_: &Review| Ok(()),
1696            )
1697            .await;
1698            self.state.quota.extend(quota_losses);
1699
1700            let mut records = Vec::new();
1701            let mut all_findings = Vec::new();
1702            for (r, (seat, res)) in results.into_iter().enumerate() {
1703                let agent_id = seat.agent.clone();
1704                self.state.seats.insert(seat.key.clone(), seat);
1705                let mut record = ReviewRecord {
1706                    reviewer: r + 1,
1707                    agent: agent_id,
1708                    summary: String::new(),
1709                    findings: Vec::new(),
1710                    failed: None,
1711                    duration_ms: 0,
1712                };
1713                match res {
1714                    Ok((review, out)) => {
1715                        record.summary = review.summary;
1716                        record.duration_ms = out.duration_ms;
1717                        for (n, mut f) in review.findings.into_iter().enumerate() {
1718                            // ids are magi's, never the agent's: the fixer's
1719                            // adoption report is keyed by them.
1720                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
1721                            all_findings.push(f.clone());
1722                            record.findings.push(f);
1723                        }
1724                        self.state.event(
1725                            "review",
1726                            format!(
1727                                "round {round}: reviewer {} raised {} finding(s)",
1728                                r + 1,
1729                                record.findings.len()
1730                            ),
1731                        );
1732                    }
1733                    Err(e) => {
1734                        record.failed = Some(e.to_string());
1735                        self.state.event(
1736                            "review",
1737                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
1738                        );
1739                    }
1740                }
1741                records.push(record);
1742            }
1743
1744            let e2e = run_commands(
1745                &shell,
1746                &self.state.config.verify.e2e,
1747                &winner.worktree,
1748                Duration::from_secs(self.state.config.graph.timeout_review),
1749            )
1750            .await;
1751            for o in &e2e {
1752                self.state.event(
1753                    "verify",
1754                    format!(
1755                        "round {round}: `{}` -> {}",
1756                        o.command,
1757                        if o.ok() {
1758                            "pass".to_owned()
1759                        } else {
1760                            format!("FAIL ({:?})", o.code)
1761                        }
1762                    ),
1763                );
1764            }
1765            let e2e_failures: String = e2e
1766                .iter()
1767                .filter(|o| !o.ok())
1768                .map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
1769                .collect();
1770
1771            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
1772            let clean = blocking == 0 && e2e.iter().all(CommandOutcome::ok);
1773
1774            let mut round_record = ReviewRound {
1775                round,
1776                head: head.clone(),
1777                reviews: records,
1778                e2e,
1779                fix: None,
1780                blocking,
1781                clean,
1782            };
1783
1784            if clean {
1785                self.state.event(
1786                    "review",
1787                    format!("round {round}: clean — no blocking findings, verification green"),
1788                );
1789                self.state.reviews.push(round_record);
1790                self.state.status = RunStatus::Gating;
1791                self.state.save()?;
1792                return Ok(());
1793            }
1794
1795            if round == max_rounds {
1796                self.state.reviews.push(round_record);
1797                self.state.status = RunStatus::Blocked;
1798                self.state.event(
1799                    "review",
1800                    format!("{blocking} blocking finding(s) still open after {max_rounds} rounds"),
1801                );
1802                self.state.save()?;
1803                return Ok(());
1804            }
1805
1806            // Fix. The winner's own implementer seat continues its conversation:
1807            // the competition is over, so context is pure benefit now.
1808            let (fix_spec, fix_seat_key) = match &self.roles.fixer {
1809                Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
1810                _ => (
1811                    self.state
1812                        .config
1813                        .agent(&winner.agent)
1814                        .cloned()
1815                        .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
1816                    format!("impl-{}", winner.label),
1817                ),
1818            };
1819            let seat = self.seat(&fix_seat_key, &fix_spec.id);
1820            let blocking_findings: Vec<_> = all_findings
1821                .iter()
1822                .filter(|f| f.severity.blocks())
1823                .cloned()
1824                .collect();
1825            let job = SeatJob {
1826                prompt: prompt::fix(
1827                    &self.state.instruction,
1828                    &blocking_findings,
1829                    (!e2e_failures.is_empty()).then_some(e2e_failures.as_str()),
1830                    round,
1831                    max_rounds,
1832                    &language,
1833                ),
1834                spec: fix_spec.clone(),
1835                seat,
1836                cwd: winner.worktree.clone(),
1837                timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
1838                allow_write: true,
1839                sessions,
1840                artifacts: artifacts.clone(),
1841                stem: format!("fix-{round}"),
1842            };
1843            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
1844            let (seat, out) = run_one(job, Arc::clone(&self.sem), &run_id, "fix", &prompts).await;
1845            let agent_id = seat.agent.clone();
1846            let seat_key = seat.key.clone();
1847            self.state.seats.insert(seat.key.clone(), seat);
1848
1849            let mut fix = FixRecord {
1850                agent: agent_id,
1851                addressed: Vec::new(),
1852                rejected: Vec::new(),
1853                notes: String::new(),
1854                committed: false,
1855                failed: None,
1856                duration_ms: 0,
1857            };
1858            match out {
1859                AgentOutcome::Ok(o) => {
1860                    fix.duration_ms = o.duration_ms;
1861                    match verdict::extract_json::<FixReport>(&o.text) {
1862                        Ok(report) => {
1863                            fix.addressed = report.addressed;
1864                            fix.rejected = report.rejected;
1865                            fix.notes =
1866                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
1867                        }
1868                        Err(e) => fix.failed = Some(format!("unparsable fix report: {e}")),
1869                    }
1870                }
1871                AgentOutcome::Quota(o) => {
1872                    self.state.quota.push(QuotaLoss {
1873                        seat: seat_key,
1874                        node: "fix".to_owned(),
1875                        at: Timestamp::now(),
1876                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1877                    });
1878                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
1879                }
1880                AgentOutcome::Failed(e) => fix.failed = Some(e),
1881            }
1882            git::commit_all(
1883                &winner.worktree,
1884                &format!("magi: review round {round} fixes (uncommitted work)"),
1885            )
1886            .await
1887            .ok();
1888            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
1889            fix.committed = after != before;
1890            self.state.event(
1891                "fix",
1892                format!(
1893                    "round {round}: {} addressed, {} rejected, {}",
1894                    fix.addressed.len(),
1895                    fix.rejected.len(),
1896                    if fix.committed {
1897                        "committed"
1898                    } else {
1899                        "NO new commit"
1900                    }
1901                ),
1902            );
1903            let stalled = !fix.committed;
1904            round_record.fix = Some(fix);
1905            self.state.reviews.push(round_record);
1906            self.state.save()?;
1907
1908            prev_e2e = (!e2e_failures.is_empty()).then_some(e2e_failures);
1909
1910            if stalled {
1911                self.state.status = RunStatus::Blocked;
1912                self.state.event(
1913                    "review",
1914                    "the fixer produced no commit; stopping instead of looping on an unchanged tree"
1915                        .to_owned(),
1916                );
1917                self.state.save()?;
1918                return Ok(());
1919            }
1920        }
1921        Ok(())
1922    }
1923
1924    // ----------------------------------------------------------------- gate
1925
1926    async fn gate(&mut self) -> Result<()> {
1927        if self.state.status == RunStatus::Blocked || self.state.status == RunStatus::Failed {
1928            return Ok(());
1929        }
1930        if !self.state.gate.is_empty() {
1931            return Ok(());
1932        }
1933        let Some(winner) = self.state.winner().cloned() else {
1934            return Ok(());
1935        };
1936        self.state.status = RunStatus::Gating;
1937        let shell = self.state.config.shell();
1938        let outcomes = run_commands(
1939            &shell,
1940            &self.state.config.verify.gate,
1941            &winner.worktree,
1942            Duration::from_secs(self.state.config.graph.timeout_review),
1943        )
1944        .await;
1945        for o in &outcomes {
1946            self.state.event(
1947                "gate",
1948                format!(
1949                    "`{}` -> {}",
1950                    o.command,
1951                    if o.ok() {
1952                        "pass".to_owned()
1953                    } else {
1954                        format!("FAIL ({:?})", o.code)
1955                    }
1956                ),
1957            );
1958        }
1959        let passed = outcomes.iter().all(CommandOutcome::ok);
1960        self.state.gate = outcomes;
1961        if !passed {
1962            self.state.status = RunStatus::Blocked;
1963            self.state.event("gate", "gate failed; not merging");
1964        }
1965        self.state.save()?;
1966        Ok(())
1967    }
1968
1969    // ---------------------------------------------------------------- merge
1970
1971    async fn merge(&mut self) -> Result<()> {
1972        if self.state.status.done() && self.state.status != RunStatus::Ready {
1973            return Ok(());
1974        }
1975        let Some(winner) = self.state.winner().cloned() else {
1976            return Ok(());
1977        };
1978        let repo = self.state.repo.clone();
1979        let base = self.state.base_branch.clone();
1980        let mode = self.state.config.merge.mode;
1981        let message = format!(
1982            "Merge magi run {} (candidate {})\n\n{}",
1983            self.state.id, winner.label, self.state.instruction
1984        );
1985
1986        let outcome = match mode {
1987            MergeMode::None => MergeOutcome {
1988                mode,
1989                ok: true,
1990                detail: format!("git -C {} merge --no-ff {}", repo.display(), winner.branch),
1991            },
1992            MergeMode::Local => {
1993                let on = git::current_branch(&repo).await?;
1994                if on.as_deref() != Some(base.as_str()) {
1995                    MergeOutcome {
1996                        mode,
1997                        ok: false,
1998                        detail: format!(
1999                            "{} has {} checked out, not the base branch {base}",
2000                            repo.display(),
2001                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
2002                        ),
2003                    }
2004                } else if !git::is_clean(&repo).await? {
2005                    MergeOutcome {
2006                        mode,
2007                        ok: false,
2008                        detail: format!("{} is dirty; refusing to merge", repo.display()),
2009                    }
2010                } else {
2011                    let out = git::merge_no_ff(&repo, &winner.branch, &message).await?;
2012                    MergeOutcome {
2013                        mode,
2014                        ok: out.ok(),
2015                        detail: if out.ok() { out.stdout } else { out.stderr },
2016                    }
2017                }
2018            }
2019            MergeMode::Pr => {
2020                let remote = self.state.config.merge.remote.clone();
2021                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
2022                if !pushed.ok() {
2023                    MergeOutcome {
2024                        mode,
2025                        ok: false,
2026                        detail: pushed.stderr,
2027                    }
2028                } else {
2029                    let out = gh_pr_create(&winner.worktree, &base, &winner.branch, &message).await;
2030                    match out {
2031                        Ok(url) => MergeOutcome {
2032                            mode,
2033                            ok: true,
2034                            detail: url,
2035                        },
2036                        Err(e) => MergeOutcome {
2037                            mode,
2038                            ok: false,
2039                            detail: e.to_string(),
2040                        },
2041                    }
2042                }
2043            }
2044        };
2045
2046        self.state.status = match (mode, outcome.ok) {
2047            (MergeMode::None, _) => RunStatus::Ready,
2048            (_, true) => RunStatus::Merged,
2049            (_, false) => RunStatus::Blocked,
2050        };
2051        self.state.event(
2052            "merge",
2053            format!(
2054                "{:?}: {}",
2055                mode,
2056                outcome.detail.lines().next().unwrap_or("")
2057            ),
2058        );
2059        self.state.merge = Some(outcome);
2060        self.state.save()?;
2061
2062        // The PR is open and the run would historically stop here, leaving the
2063        // operator to watch checks, feed review comments back to a fixer, and
2064        // merge. That was done by hand six times in one session before this
2065        // existed. Opt-in, because merging is the one irreversible thing magi
2066        // can do to a repository.
2067        if self.state.config.graph.land
2068            && mode == MergeMode::Pr
2069            && self.state.status == RunStatus::Merged
2070        {
2071            let url = self
2072                .state
2073                .merge
2074                .as_ref()
2075                .map(|m| m.detail.clone())
2076                .unwrap_or_default();
2077            let url = url.lines().next().unwrap_or("").trim().to_owned();
2078            if url.starts_with("http") {
2079                // A land failure is not a lost run: the work is on a branch and
2080                // the PR is open, which is exactly where a human takes over.
2081                match land::land(&mut self.state, &url).await {
2082                    Ok(pr) => {
2083                        self.state.status = match pr.state {
2084                            land::PrLifecycle::Merged => RunStatus::Merged,
2085                            _ => RunStatus::Blocked,
2086                        };
2087                    }
2088                    Err(e) => {
2089                        self.state.status = RunStatus::Blocked;
2090                        self.state.event("land", format!("gave up: {e}"));
2091                    }
2092                }
2093                self.state.save()?;
2094            }
2095        }
2096        Ok(())
2097    }
2098
2099    // -------------------------------------------------------------- helpers
2100
2101    /// Fetch or create a seat, keeping its conversation across nodes.
2102    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
2103        if let Some(existing) = self.state.seats.get(key)
2104            && existing.agent == agent
2105        {
2106            return existing.clone();
2107        }
2108        let fresh = SeatState::new(key, agent, self.state.seed);
2109        self.state.seats.insert(key.to_owned(), fresh.clone());
2110        fresh
2111    }
2112
2113    /// A candidate rendered for judging, with the leak policy applied.
2114    fn view(&self, c: &Candidate) -> CandidateView {
2115        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2116            .unwrap_or_default();
2117        let (patch, _) = blind::sanitize_patch(
2118            &format!("candidate {} patch", c.label),
2119            &raw,
2120            &self.state.config.blind,
2121        );
2122        CandidateView {
2123            label: c.label,
2124            branch: c.branch.clone(),
2125            summary: c.summary.clone(),
2126            stat: c.stat.clone(),
2127            patch,
2128        }
2129    }
2130
2131    /// The full candidate set as prompt text, for seats with no live session.
2132    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
2133        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
2134        prompt::judge(
2135            "(see above)",
2136            &views,
2137            self.roles.judges.len(),
2138            base_short,
2139            "en",
2140        )
2141    }
2142
2143    /// Anonymised transcript for judge `self_idx`.
2144    ///
2145    /// The initial rankings are always the opening statements. Seeding them
2146    /// only when no turn had been taken yet meant every judge after the first
2147    /// argued against a single voice instead of against the actual split — the
2148    /// disagreement is the information, so it is always on the table.
2149    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
2150        let mut turns = Vec::new();
2151        for j in &self.state.judgements {
2152            if j.ranking.is_empty() {
2153                continue;
2154            }
2155            let reasons = j
2156                .reasons
2157                .iter()
2158                .map(|(k, v)| format!("- {k}: {v}"))
2159                .collect::<Vec<_>>()
2160                .join("\n");
2161            turns.push(Turn {
2162                who: format!("Judge {} (opening ranking)", j.judge),
2163                is_self: j.judge == self_idx + 1,
2164                body: format!(
2165                    "Ranked {}{}{reasons}",
2166                    j.ranking.iter().collect::<String>(),
2167                    if reasons.is_empty() {
2168                        ""
2169                    } else {
2170                        ", because:\n"
2171                    }
2172                ),
2173            });
2174        }
2175        for t in self
2176            .state
2177            .deliberation
2178            .iter()
2179            .flat_map(|r| r.turns.iter())
2180            .chain(current)
2181        {
2182            turns.push(Turn {
2183                who: format!("Judge {}", t.judge),
2184                is_self: t.judge == self_idx + 1,
2185                body: t.body.clone(),
2186            });
2187        }
2188        turns
2189    }
2190}
2191
2192/// Does this seat still hold the context a follow-up prompt would rely on?
2193fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
2194    agent::has_session(spec.kind, seat, sessions)
2195}
2196
2197fn short(commit: &str) -> String {
2198    commit.chars().take(7).collect()
2199}
2200
2201fn make_executable(path: &Path) -> Result<()> {
2202    #[cfg(unix)]
2203    {
2204        use std::os::unix::fs::PermissionsExt as _;
2205        let mut perms = std::fs::metadata(path)?.permissions();
2206        perms.set_mode(0o755);
2207        std::fs::set_permissions(path, perms)?;
2208    }
2209    #[cfg(not(unix))]
2210    {
2211        let _ = path;
2212    }
2213    Ok(())
2214}
2215
2216/// Run one job, honouring the parallelism budget.
2217async fn run_one(
2218    job: SeatJob,
2219    sem: Arc<Semaphore>,
2220    run: &str,
2221    node: &str,
2222    prompts: &Prompts,
2223) -> (SeatState, AgentOutcome) {
2224    let (_, seat, out) = wave(vec![job], sem, run, node, prompts)
2225        .await
2226        .pop()
2227        .expect("one job in, one result out");
2228    (seat, out)
2229}
2230
2231/// Run every job concurrently, capped by the semaphore, preserving order.
2232///
2233/// `run` and `node` are attribution, not behaviour: they reach the agent as
2234/// `MAGI_RUN` / `MAGI_NODE` so a task the agent files with `magi task add` can
2235/// name the seat that asked for it. They are cloned per job because each job is
2236/// spawned onto its own task and cannot borrow from this frame.
2237async fn wave(
2238    jobs: Vec<SeatJob>,
2239    sem: Arc<Semaphore>,
2240    run: &str,
2241    node: &str,
2242    prompts: &Prompts,
2243) -> Vec<(usize, SeatState, AgentOutcome)> {
2244    let mut set = tokio::task::JoinSet::new();
2245    let overlay = prompts.overlay(node);
2246    for (i, mut job) in jobs.into_iter().enumerate() {
2247        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
2248        let sem = Arc::clone(&sem);
2249        let run = run.to_owned();
2250        let node = node.to_owned();
2251        set.spawn(async move {
2252            let _permit = sem.acquire().await;
2253            let mut seat = job.seat;
2254            let out = agent::invoke(
2255                &job.spec,
2256                &mut seat,
2257                &Invocation {
2258                    cwd: &job.cwd,
2259                    prompt: &job.prompt,
2260                    timeout: job.timeout,
2261                    allow_write: job.allow_write,
2262                    sessions: job.sessions,
2263                    artifacts: &job.artifacts,
2264                    stem: &job.stem,
2265                    run: &run,
2266                    node: &node,
2267                },
2268            )
2269            .await;
2270            let out = match out {
2271                Ok(o) if o.usable() => AgentOutcome::Ok(o),
2272                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
2273                Ok(o) if o.timed_out => AgentOutcome::Failed("timed out".to_owned()),
2274                Ok(o) => AgentOutcome::Failed(format!(
2275                    "exited with {:?} and no usable output",
2276                    o.exit_code
2277                )),
2278                Err(e) => AgentOutcome::Failed(e.to_string()),
2279            };
2280            (i, seat, out)
2281        });
2282    }
2283    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
2284    while let Some(joined) = set.join_next().await {
2285        let (i, seat, out) = match joined {
2286            Ok(v) => v,
2287            Err(e) => {
2288                tracing::error!("agent task panicked: {e}");
2289                continue;
2290            }
2291        };
2292        if collected.len() <= i {
2293            collected.resize_with(i + 1, || None);
2294        }
2295        collected[i] = Some((i, seat, out));
2296    }
2297    collected.into_iter().flatten().collect()
2298}
2299
2300/// How long a re-ask may take, given the budget the first attempt had.
2301///
2302/// A `nudged` retry is a request to restate an answer the seat has already
2303/// worked out: it carries no new work, so it does not deserve the original
2304/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
2305/// 133 seconds while a third sat for over ten minutes on a resumed session
2306/// holding 410 KB of prior output - and because the retry had inherited the
2307/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
2308/// judging round whose other seats were long finished.
2309///
2310/// A quarter of the budget, with a floor so that a deliberately short timeout
2311/// does not collapse to nothing. A retry that re-sends the whole prompt
2312/// (because the seat kept no context) is the original job again, and keeps the
2313/// original budget.
2314fn retry_budget(full: Duration, nudged: bool) -> Duration {
2315    if nudged {
2316        (full / 4).max(Duration::from_secs(120)).min(full)
2317    } else {
2318        full
2319    }
2320}
2321
2322/// Run a wave and parse each reply, re-asking the seats whose reply was
2323/// unusable.
2324///
2325/// The re-ask is a nudge rather than the whole prompt again when the seat still
2326/// holds its conversation, which is the difference between a cheap retry and
2327/// paying for the entire candidate set twice.
2328///
2329/// A seat that hits a rate limit is **not** re-asked: the same call will fail
2330/// the same way until the limit resets, so spending a retry attempt on it is
2331/// pure waste. Its loss is recorded in `losses` and it is returned as a failure
2332/// like any other absent seat — the caller decides whether the panel still has
2333/// a quorum.
2334#[allow(clippy::too_many_arguments)]
2335async fn ask_json_wave<T>(
2336    jobs: Vec<SeatJob>,
2337    sem: Arc<Semaphore>,
2338    retries: usize,
2339    run: &str,
2340    node: &str,
2341    prompts: &Prompts,
2342    losses: &mut Vec<QuotaLoss>,
2343    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
2344) -> Vec<(SeatState, Result<(T, AgentOutput)>)>
2345where
2346    T: serde::de::DeserializeOwned + Send + 'static,
2347{
2348    let n = jobs.len();
2349    let originals: Vec<SeatJob> = jobs;
2350    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
2351    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
2352    let mut pending: Vec<usize> = (0..n).collect();
2353
2354    for attempt in 0..=retries {
2355        if pending.is_empty() {
2356            break;
2357        }
2358        let mut batch = Vec::with_capacity(pending.len());
2359        for &i in &pending {
2360            let src = &originals[i];
2361            // The prompt and the budget are one decision: a nudge restates
2362            // finished work, a re-sent prompt redoes it.
2363            let (prompt, timeout) = if attempt == 0 {
2364                (src.prompt.clone(), src.timeout)
2365            } else {
2366                let why = done[i]
2367                    .as_ref()
2368                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
2369                    .unwrap_or_else(|| "no parsable answer".to_owned());
2370                let nudge = prompt::nudge(&why);
2371                let nudged = has_context(&src.spec, &seats[i], src.sessions);
2372                let prompt = if nudged {
2373                    nudge
2374                } else {
2375                    format!("{}\n\n---\n\n{}", src.prompt, nudge)
2376                };
2377                (prompt, retry_budget(src.timeout, nudged))
2378            };
2379            batch.push(SeatJob {
2380                spec: src.spec.clone(),
2381                seat: seats[i].clone(),
2382                cwd: src.cwd.clone(),
2383                prompt,
2384                timeout,
2385                allow_write: src.allow_write,
2386                sessions: src.sessions,
2387                artifacts: src.artifacts.clone(),
2388                stem: if attempt == 0 {
2389                    src.stem.clone()
2390                } else {
2391                    format!("{}-retry{attempt}", src.stem)
2392                },
2393            });
2394        }
2395
2396        let results = wave(batch, Arc::clone(&sem), run, node, prompts).await;
2397        let mut still = Vec::new();
2398        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
2399            seats[i] = seat;
2400            let (parsed, quota) = match out {
2401                AgentOutcome::Ok(o) => (
2402                    match verdict::extract_json::<T>(&o.text) {
2403                        Ok(v) => match validate(&v) {
2404                            Ok(()) => Ok((v, o)),
2405                            Err(e) => Err(e),
2406                        },
2407                        Err(e) => Err(e),
2408                    },
2409                    false,
2410                ),
2411                AgentOutcome::Quota(o) => {
2412                    losses.push(QuotaLoss {
2413                        seat: originals[i].seat.key.clone(),
2414                        node: node.to_owned(),
2415                        at: Timestamp::now(),
2416                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2417                    });
2418                    (
2419                        Err(anyhow::anyhow!("rate limited (quota); not retrying now")),
2420                        true,
2421                    )
2422                }
2423                AgentOutcome::Failed(e) => (Err(anyhow::anyhow!(e)), false),
2424            };
2425            let failed = parsed.is_err();
2426            done[i] = Some(parsed);
2427            // Do not re-ask a rate-limited seat (quota) — a retry is known to
2428            // fail the same way; and never re-ask a seat that already parsed.
2429            if failed && !quota {
2430                still.push(i);
2431            }
2432        }
2433        pending = still;
2434    }
2435
2436    seats
2437        .into_iter()
2438        .zip(done)
2439        .map(|(seat, res)| {
2440            (
2441                seat,
2442                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
2443            )
2444        })
2445        .collect()
2446}
2447
2448/// Run configured shell commands in `cwd`, in order.
2449async fn run_commands(
2450    shell: &[String],
2451    commands: &[String],
2452    cwd: &Path,
2453    timeout: Duration,
2454) -> Vec<CommandOutcome> {
2455    let mut out = Vec::new();
2456    for command in commands {
2457        let started = Instant::now();
2458        let mut cmd = tokio::process::Command::new(&shell[0]);
2459        cmd.args(&shell[1..])
2460            .arg(command)
2461            .current_dir(cwd)
2462            .stdin(std::process::Stdio::null())
2463            .stdout(std::process::Stdio::piped())
2464            .stderr(std::process::Stdio::piped())
2465            .kill_on_drop(true);
2466        let spawned = cmd.spawn();
2467        let (code, body) = match spawned {
2468            Ok(child) => match tokio::time::timeout(timeout, child.wait_with_output()).await {
2469                Ok(Ok(o)) => {
2470                    let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
2471                    body.push_str(&String::from_utf8_lossy(&o.stderr));
2472                    (o.status.code(), body)
2473                }
2474                Ok(Err(e)) => (None, format!("failed to run: {e}")),
2475                Err(_) => (None, format!("timed out after {}s", timeout.as_secs())),
2476            },
2477            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
2478        };
2479        out.push(CommandOutcome {
2480            command: command.clone(),
2481            code,
2482            output_tail: tail(&body, OUTPUT_TAIL),
2483            duration_ms: started.elapsed().as_millis() as u64,
2484        });
2485    }
2486    out
2487}
2488
2489/// `gh pr create`, returning the PR url.
2490async fn gh_pr_create(cwd: &Path, base: &str, head: &str, body: &str) -> Result<String> {
2491    let title = body.lines().next().unwrap_or("magi run").to_owned();
2492    let out = tokio::process::Command::new("gh")
2493        .args([
2494            "pr", "create", "--base", base, "--head", head, "--title", &title, "--body", body,
2495        ])
2496        .current_dir(cwd)
2497        .stdin(std::process::Stdio::null())
2498        .output()
2499        .await
2500        .context("spawn gh")?;
2501    if out.status.success() {
2502        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
2503    } else {
2504        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
2505    }
2506}
2507
2508/// Tear a run's worktrees and branches down.
2509pub async fn fold_run(state: &mut RunState, drop_winner: bool) -> Result<Vec<String>> {
2510    let repo = state.repo.clone();
2511    let root = state.worktree_root();
2512    let winner = state.tally.as_ref().map(|t| t.winner);
2513    let mut removed = Vec::new();
2514
2515    for i in 0..state.candidates.len() {
2516        let c = state.candidates[i].clone();
2517        let is_winner = Some(c.label) == winner;
2518        if is_winner && !drop_winner {
2519            continue;
2520        }
2521        if c.worktree.exists() {
2522            git::worktree_remove(&repo, &c.worktree).await.ok();
2523            removed.push(c.worktree.to_string_lossy().into_owned());
2524        }
2525        if git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
2526            git::branch_delete(&repo, &c.branch).await.ok();
2527            removed.push(c.branch.clone());
2528        }
2529        state.candidates[i].folded = true;
2530    }
2531
2532    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
2533        let path = name.path();
2534        let keep = !drop_winner
2535            && winner.is_some_and(|w| {
2536                path.file_name()
2537                    .is_some_and(|n| n == format!("cand-{w}").as_str())
2538            });
2539        if keep {
2540            continue;
2541        }
2542        git::worktree_remove(&repo, &path).await.ok();
2543        removed.push(path.to_string_lossy().into_owned());
2544    }
2545
2546    if state.enabled_worktree_config && drop_winner {
2547        git::disable_worktree_config(&repo).await.ok();
2548        state.enabled_worktree_config = false;
2549    }
2550    state.save()?;
2551    Ok(removed)
2552}
2553
2554/// Severity of the worst open finding in the last review round, for reporting.
2555pub fn worst_open(state: &RunState) -> Option<Severity> {
2556    state
2557        .reviews
2558        .last()?
2559        .reviews
2560        .iter()
2561        .flat_map(|r| r.findings.iter())
2562        .map(|f| f.severity)
2563        .max()
2564}
2565
2566#[cfg(test)]
2567mod tests {
2568    use super::retry_budget;
2569    use std::time::Duration;
2570
2571    fn secs(n: u64) -> Duration {
2572        Duration::from_secs(n)
2573    }
2574
2575    #[test]
2576    fn a_nudge_gets_a_quarter_of_the_budget() {
2577        // The judge and implement budgets magi ships with.
2578        assert_eq!(retry_budget(secs(1200), true), secs(300));
2579        assert_eq!(retry_budget(secs(3600), true), secs(900));
2580    }
2581
2582    #[test]
2583    fn a_resent_prompt_keeps_the_whole_budget() {
2584        // The seat kept no context, so the retry is the original job again and
2585        // shortening it would only guarantee a second failure.
2586        assert_eq!(retry_budget(secs(1200), false), secs(1200));
2587        assert_eq!(retry_budget(secs(60), false), secs(60));
2588    }
2589
2590    #[test]
2591    fn the_floor_never_exceeds_the_original_budget() {
2592        // A short configured timeout must not be *raised* by the floor: the
2593        // operator asked for a bound, and a retry may not outlast the attempt
2594        // it is retrying.
2595        assert_eq!(retry_budget(secs(60), true), secs(60));
2596        assert_eq!(retry_budget(secs(480), true), secs(120));
2597        assert_eq!(retry_budget(secs(0), true), secs(0));
2598    }
2599}