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::ask;
31use crate::blind;
32use crate::bump;
33use crate::config::{
34    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
35    ResolvedRoles,
36};
37use crate::git;
38use crate::land;
39use crate::proc::Quiet as _;
40use crate::prompt::{
41    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
42};
43use crate::run::{
44    BaseSync, Candidate, CommandOutcome, DeliberationRound, DeliberationTurn, FixRecord, Judgement,
45    MergeOutcome, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus,
46    Tally, VoteRecord, tail, write_artifact,
47};
48use crate::verdict::{
49    self, FinalVote, FixReport, Position, Ranking, Review, ReviewRevote, ReviewVote, Severity,
50};
51
52/// How much verification output is kept and fed back to the fixer.
53const OUTPUT_TAIL: usize = 8_000;
54
55/// Bytes of a failing command's output kept in an event, so the reason a run
56/// stopped is readable from the report without opening `run.json`.
57const EVENT_OUTPUT_TAIL: usize = 2_000;
58
59/// Consecutive review rounds with no tree progress (see
60/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
61/// instead of spending the rest of the round budget.
62///
63/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
64/// legitimately finds nothing left to change (its previous round's fix already
65/// covered it, and this round's reviewers re-raised only nits) looks the same
66/// as one that is spinning, for exactly one round. Two in a row is where the
67/// two stop being distinguishable, and a review round on this workload has
68/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
69/// third attempt at a tree that has not moved twice running is pure cost.
70/// This does not touch `review_rounds` itself, which stays the operator's
71/// call.
72pub(crate) const STAGNANT_LIMIT: usize = 2;
73
74/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
75/// a base that moved before giving up and leaving the run `Blocked` for a
76/// person.
77///
78/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
79/// that keeps moving faster than a run can catch it is not something more
80/// rebasing fixes, it is a person's call. Not the same *number as*
81/// `land_rounds` - this budget is spent before a pull request exists, land's
82/// after - but bounded for the identical reason, so it uses the same
83/// default. Counted across both call sites in [`Runner::finish_after_tally`]
84/// (once before review, once before the gate), because either one finding
85/// the base still moving is the same signal.
86const BASE_SYNC_ROUNDS: usize = 4;
87
88/// One queued agent invocation.
89///
90/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
91/// CLI hung up on its own stream is asked again from the same job rather than
92/// rebuilt from scratch. See [`Runner::resume_undelivered`].
93#[derive(Clone)]
94struct SeatJob {
95    spec: AgentSpec,
96    seat: SeatState,
97    cwd: PathBuf,
98    prompt: String,
99    timeout: Duration,
100    allow_write: bool,
101    sessions: bool,
102    artifacts: PathBuf,
103    stem: String,
104}
105
106/// How the graph reads one agent invocation.
107///
108/// Quota is split out from an ordinary failure on purpose: a rate-limited call
109/// is known to fail again if retried now, so the retry loop must not spend an
110/// attempt on it. `Dropped` is split out for the opposite reason: unlike
111/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
112/// error JSON, never the agent's answer — a caller that matched only
113/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
114/// left to read that JSON as if it were usable output. `resume_undelivered`
115/// is the only caller that acts on it; everywhere else it is reported like an
116/// ordinary failure.
117enum AgentOutcome {
118    /// A usable output.
119    Ok(AgentOutput),
120    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
121    Quota(AgentOutput),
122    /// The CLI hung up on its own stream after billed work. See
123    /// [`agent::AgentOutput::work_undelivered`].
124    Dropped(AgentOutput),
125    /// Any other failure: a timeout, a bad exit code, an empty reply.
126    Failed(String),
127}
128
129/// A request to park the run at its next node boundary.
130///
131/// Cloning is how the request travels: the loop keeps one handle and hands a
132/// clone to each [`Runner`], and every clone points at the same flag. There
133/// is no channel because there is nothing to send - the only message is
134/// "park", it is idempotent, and a flag cannot be missed by a receiver that
135/// was not listening yet.
136///
137/// The boundary is what makes this cheap. Every node writes the run's state
138/// before the next one starts, and every node skips what is already recorded:
139/// `prep` returns early once candidates exist, `implement` asks only the seats
140/// with nothing on disk, `judge` returns early once judgements exist. So a
141/// parked run resumes into exactly the node it stopped before, and no agent
142/// work is thrown away. Killing the process mid-node, by contrast, loses
143/// whatever the seats in flight had not yet written - which for an implement
144/// wave is an hour of paid work.
145#[derive(Debug, Clone, Default)]
146pub struct Pause(Arc<AtomicBool>);
147
148impl Pause {
149    /// A pause nobody has asked for yet.
150    #[must_use]
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Ask the run to park at its next node boundary. Idempotent.
156    pub fn park(&self) {
157        self.0.store(true, Ordering::SeqCst);
158    }
159
160    /// Has a park been asked for?
161    #[must_use]
162    pub fn parked(&self) -> bool {
163        self.0.load(Ordering::SeqCst)
164    }
165}
166
167/// Drives one run.
168pub struct Runner {
169    /// Run state; public so the CLI can report on it.
170    pub state: RunState,
171    roles: ResolvedRoles,
172    sem: Arc<Semaphore>,
173    /// Set when someone wants the run parked at its next node boundary.
174    pause: Pause,
175}
176
177/// The commit a run branches from: the base branch as the remote has it.
178///
179/// Two failures this replaces. A run used to branch off `HEAD` and so refused
180/// to start on a dirty tree, which made `magi serve` decline every task for as
181/// long as the operator had work in progress - most of the time. Branching off
182/// the *local* base branch fixed that and introduced a worse one: `land` merges
183/// the winner on GitHub, nothing updates the local ref, and the next run
184/// branches off a base missing everything the previous runs landed. Two tasks
185/// in a row from a phone would have had the second silently re-implementing
186/// against stale code and opening a pull request that reverted the first.
187///
188/// Only refs move here - no checkout, no local branch, no merge - so it is safe
189/// with uncommitted work in the tree. A machine with no network still starts:
190/// the fetch may fail and the local tip is used with a warning, because
191/// refusing to run offline is a worse failure than running against a base the
192/// operator can see for themselves.
193///
194/// One function, called by both entry points. Two answers to "where does a run
195/// branch from" is the kind of drift nobody notices until a diff is wrong.
196async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
197    let tracking = format!("{remote}/{base_branch}");
198    let fetched = git::fetch(repo, remote, base_branch).await;
199    if let Ok(out) = &fetched
200        && out.ok()
201        && git::rev_exists(repo, &tracking).await
202    {
203        return git::rev_parse(repo, &tracking).await;
204    }
205    let why = match &fetched {
206        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
207        Ok(_) => format!("{remote} has no {base_branch}"),
208        Err(e) => e.to_string(),
209    };
210    tracing::warn!(
211        "could not read {tracking} ({why}); branching off the local \
212         {base_branch} instead, which may be behind"
213    );
214    git::rev_parse(repo, base_branch).await.with_context(|| {
215        format!(
216            "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
217             branch that exists"
218        )
219    })
220}
221
222impl Runner {
223    /// Start a fresh run against `repo`.
224    pub async fn start(repo: &Path, instruction: String, config: Config) -> Result<Self> {
225        let repo = git::toplevel(repo).await?;
226        let missing = agent::missing_programs(&config.agents);
227        if !missing.is_empty() {
228            bail!(
229                "these agent programs are not on PATH: {}. Fix the roster in \
230                 magi.toml or install them.",
231                missing.join(", ")
232            );
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        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
241        // Still worth saying out loud. The operator's uncommitted work is not
242        // part of this run, and someone watching a candidate fail to use a
243        // change they just made deserves to know why.
244        if !git::is_clean(&repo).await? {
245            tracing::warn!(
246                "{} has uncommitted changes; they are not part of this run, \
247                 which branches off {base_branch} ({})",
248                repo.display(),
249                &base_commit[..base_commit.len().min(8)]
250            );
251        }
252        let roles = config.resolve_roles()?;
253        let max_parallel = config.graph.max_parallel.max(1);
254        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
255        state.event("start", format!("run {} created", state.id));
256        state.save()?;
257        Ok(Self {
258            state,
259            roles,
260            sem: Arc::new(Semaphore::new(max_parallel)),
261            pause: Pause::new(),
262        })
263    }
264
265    /// Open a review-only run against work that already exists on `branch`.
266    ///
267    /// The expensive half of the graph is the implement wave — measured at
268    /// 111 and 134 internal tool-loop turns on this repository, against a
269    /// handful for a judge or a reviewer. The cheap half is worth running on
270    /// hand-written work too, and there was no way to reach it.
271    ///
272    /// No new state and no schema change are needed: a run with **one** viable
273    /// candidate and a tally already decided degrades `execute` to exactly
274    /// review → gate → merge, because `judge` skips a single-candidate field,
275    /// `deliberate` has fewer than two first choices to reconcile, `vote`
276    /// returns early, `tally` is already present and `fold_losers` has no
277    /// losers. Resuming such a run therefore does the right thing as well.
278    pub async fn review(repo: &Path, branch: &str, config: Config) -> Result<Self> {
279        let repo = git::toplevel(repo).await?;
280        let missing = agent::missing_programs(&config.agents);
281        if !missing.is_empty() {
282            bail!(
283                "these agent programs are not on PATH: {}. Fix the roster in \
284                 magi.toml or install them.",
285                missing.join(", ")
286            );
287        }
288        if !git::branch_exists(&repo, branch).await? {
289            bail!("no branch `{branch}` in {}", repo.display());
290        }
291        let base_branch = match config.merge.base.clone() {
292            Some(b) => b,
293            None => git::current_branch(&repo)
294                .await?
295                .context("HEAD is detached; set [merge] base in magi.toml")?,
296        };
297        if base_branch == branch {
298            bail!("`{branch}` is the base branch; there is nothing to review against");
299        }
300        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
301
302        let roles = config.resolve_roles()?;
303        let max_parallel = config.graph.max_parallel.max(1);
304        // The commit subjects are the closest thing to a task statement that
305        // existing work carries, and the reviewers are told as much.
306        let log = git::log_oneline(&repo, &base_commit, branch)
307            .await
308            .unwrap_or_default();
309        let instruction = format!(
310            "Review the work already on branch `{branch}`. There is no task \
311             statement: what the change claims to do is whatever its commits \
312             say.\n\n{}",
313            if log.trim().is_empty() {
314                "(no commit messages)"
315            } else {
316                log.trim()
317            }
318        );
319        let mut state = RunState::new(
320            repo.clone(),
321            base_branch,
322            base_commit.clone(),
323            instruction,
324            config,
325        );
326
327        // An attached worktree, so the fixer's commits land on the branch under
328        // review rather than on a detached head nobody will look at again.
329        let worktree = state.worktree_root().join("under-review");
330        if let Some(parent) = worktree.parent() {
331            tokio::fs::create_dir_all(parent).await.ok();
332        }
333        let path = worktree.to_string_lossy().to_string();
334        git::git(&repo, &["worktree", "add", &path, branch])
335            .await
336            .with_context(|| {
337                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
338            })?;
339
340        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
341            .await
342            .unwrap_or(0);
343        if commits == 0 {
344            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
345        }
346        let files = git::changed_files(&worktree, &base_commit, "HEAD")
347            .await
348            .map(|f| f.len())
349            .unwrap_or(0);
350        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
351            .await
352            .unwrap_or_default();
353
354        state.candidates.push(Candidate {
355            index: 0,
356            label: 'A',
357            // Not an agent id on purpose: nothing in the roster wrote this, and
358            // the stats tables must not credit anyone with a win for it.
359            agent: "(existing branch)".to_owned(),
360            branch: branch.to_owned(),
361            worktree,
362            summary: String::new(),
363            stat,
364            files,
365            commits,
366            empty: false,
367            failed: None,
368            duration_ms: 0,
369            folded: false,
370        });
371        state.tally = Some(Tally {
372            first_choice: BTreeMap::from([('A', 0)]),
373            borda: BTreeMap::new(),
374            winner: 'A',
375            rankings: 0,
376            unanimous_initial: false,
377            deliberated: false,
378            changed_votes: 0,
379            unanimous_final: false,
380            tie_break: None,
381            // No panel sat, so no quorum applies. Zero judges is the correct
382            // number for work that never competed, and must not be reported as
383            // a collapsed panel.
384            judges: 0,
385            present: 0,
386            quorum: 0,
387            met_quorum: true,
388            uncontested: Some("review-only run: nothing competed".to_owned()),
389        });
390        state.status = RunStatus::Reviewing;
391        state.event(
392            "start",
393            format!(
394                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
395                state.id
396            ),
397        );
398        state.save()?;
399        Ok(Self {
400            state,
401            roles,
402            sem: Arc::new(Semaphore::new(max_parallel)),
403            pause: Pause::new(),
404        })
405    }
406
407    /// Reopen an existing run.
408    pub fn resume(id: &str) -> Result<Self> {
409        let state = RunState::load(id)?;
410        let roles = state.config.resolve_roles()?;
411        let max_parallel = state.config.graph.max_parallel.max(1);
412        Ok(Self {
413            state,
414            roles,
415            sem: Arc::new(Semaphore::new(max_parallel)),
416            pause: Pause::new(),
417        })
418    }
419
420    /// Walk the graph to a terminal state, skipping nodes already recorded.
421    pub async fn execute(&mut self) -> Result<()> {
422        // Moving again, so it is no longer parked. Set before the walk rather
423        // than in `resume`, so every way of re-entering the graph clears it
424        // and a card cannot claim a run is waiting to be resumed while the
425        // agents are already working.
426        self.state.parked = false;
427        // Any seat this state still lists as answering belongs to whatever
428        // process last drove this run — this one included, if it crashed
429        // mid-wave. Cleared and flushed immediately, before anything else
430        // runs, so a resume can never show a seat as live when nothing is
431        // asking it anything yet; the node that actually dispatches the next
432        // wave repopulates it.
433        if self.state.clear_active() {
434            self.state.save()?;
435        }
436        // A run that already lost its quorum never resumes into the verdict
437        // machinery: `deliberate` and `vote` would otherwise clobber the
438        // stalled marker back to Voting and the run would keep going past a
439        // verdict that is no longer trustworthy. Everything already recorded is
440        // kept, so the run stays resumable (or foldable) for a human to pick up.
441        //
442        // On --resume the run gets one chance to repair itself: the seats a
443        // rate limit took out are re-asked. If their quota has since reset and
444        // the quorum is restored, the run picks up and finishes; otherwise it
445        // stays stale and still-resumable for a later retry. If it does not
446        // recover, the returned status stays `Stalled` and nothing was
447        // clobbered (the recovery only mutates entries for the lost seats).
448        if self.state.status == RunStatus::Stalled {
449            if self.recover_stall().await? {
450                self.finish_after_tally().await?;
451            } else {
452                // Still below quorum: persist the marker and stay resumable.
453                self.state.save()?;
454            }
455            return Ok(());
456        }
457        // A run parked inside `land` - watching CI, mid fix-round, or
458        // waiting on the owner's merge approval - resumes directly into it,
459        // never back through `prep`. Everything before `merge` already
460        // concluded; that is the only way `status` reaches `Landing` in the
461        // first place. Re-walking `review_loop` first would also be actively
462        // wrong: its own status recomputation (see its doc) treats any
463        // clean round as reason to set `status` to `Gating`, which would
464        // clobber this marker before `merge` ever ran, and this run would
465        // never find its way back into `land` at all.
466        if self.state.status == RunStatus::Landing {
467            self.run_land().await?;
468            // `run_land` may have settled the run right here - CI came back
469            // green and the PR merged, say - without ever passing back
470            // through `merge`'s own trailing call. Whatever it left `status`
471            // as is what this has to read.
472            self.settle_questions();
473            return Ok(());
474        }
475        self.prep().await?;
476        if self.park_here()? {
477            return Ok(());
478        }
479        self.implement().await?;
480        if self.park_here()? {
481            return Ok(());
482        }
483        self.judge().await?;
484        if self.park_here()? {
485            return Ok(());
486        }
487        self.deliberate().await?;
488        if self.park_here()? {
489            return Ok(());
490        }
491        self.vote().await?;
492        if self.park_here()? {
493            return Ok(());
494        }
495        self.tally()?;
496        // A verdict that lost its quorum is not trustworthy: do not review,
497        // gate, or merge on it. Everything already done is kept, so the run
498        // stays resumable (or foldable); the human can replace the agent that
499        // ran out of quota and pick it up.
500        if self.state.status == RunStatus::Stalled {
501            // Persist the stalled marker now — the normal end-of-execute save
502            // below is below this early return, and without it a resumed run
503            // would reload a pre-tally status and keep going.
504            self.state.save()?;
505            return Ok(());
506        }
507        self.finish_after_tally().await?;
508        Ok(())
509    }
510
511    /// Park here if asked to, recording it in the run's own timeline.
512    ///
513    /// Returns whether the caller should stop walking the graph. The state is
514    /// saved either way by the node that just finished; this adds the event so
515    /// the operator's card says why a run that is neither finished nor moving
516    /// is sitting where it is.
517    fn park_here(&mut self) -> Result<bool> {
518        if !self.pause.parked() {
519            return Ok(false);
520        }
521        self.state.event(
522            "park",
523            format!(
524                "parked after `{}` — resume to carry on from here",
525                self.state.status.as_str()
526            ),
527        );
528        self.state.parked = true;
529        self.state.save()?;
530        Ok(true)
531    }
532
533    /// Hand the runner a pause to watch.
534    pub fn on_pause(&mut self, pause: Pause) {
535        self.pause = pause;
536    }
537
538    /// Abandon this run's own open questions, once `status` has actually
539    /// settled rather than merely paused.
540    ///
541    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
542    /// either back up with the candidates, the review round and the seat
543    /// sessions already on disk, so a question an implementer asked mid-round
544    /// may still get a real answer read by a real resume. Only the three
545    /// statuses `resumable` excludes are actually final: the run merged, or
546    /// it reached `Ready` with nothing left to do, or it failed outright with
547    /// no established point to continue from. In every one of those the seat
548    /// that asked is gone for good, exactly like the run being deleted under
549    /// `magi run rm` - so the same cleanup applies, worded for what actually
550    /// happened instead of "the run was deleted".
551    ///
552    /// Best-effort and silent on success: called from every place `status`
553    /// can land on one of those three, including ones a resumed run revisits,
554    /// so it must cost nothing when there was nothing open to begin with.
555    fn settle_questions(&mut self) {
556        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
557            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
558        }
559    }
560
561    /// The tail of the graph after a trustworthy tally: fold losers, review,
562    /// gate, merge, and persist.
563    async fn finish_after_tally(&mut self) -> Result<()> {
564        self.fold_losers().await?;
565        // Before review starts, and again right before the gate: a run's
566        // review rounds can themselves take long enough for the base to move
567        // a second time, and the gate is the one node whose "green" gets
568        // acted on.
569        self.sync_to_base().await?;
570        self.review_loop().await?;
571        self.sync_to_base().await?;
572        self.gate().await?;
573        self.merge().await?;
574        self.state.save()?;
575        Ok(())
576    }
577
578    // ---------------------------------------------------------------- prep
579
580    async fn prep(&mut self) -> Result<()> {
581        if !self.state.candidates.is_empty() {
582            return Ok(());
583        }
584        self.state.status = RunStatus::Prep;
585        let repo = self.state.repo.clone();
586        let base = self.state.base_commit.clone();
587        let root = self.state.worktree_root();
588        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
589
590        // The hook is the write-time half of the blindness contract; the
591        // presentation filter in `blind` is the half that cannot be bypassed.
592        let hooks_dir = self.state.dir().join("hooks");
593        if self.state.config.blind.commit_msg_hook {
594            std::fs::create_dir_all(&hooks_dir)
595                .with_context(|| format!("create {}", hooks_dir.display()))?;
596            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
597            let path = hooks_dir.join("commit-msg");
598            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
599            make_executable(&path)?;
600            // Ref-counted rather than a plain idempotent set: with more than
601            // one run able to be in flight in the same repository at once
602            // (see `Config::daemon.max_concurrent_runs`), a bare "already
603            // true?" check cannot tell "another run of mine still needs
604            // this" from "nobody does", and the run that happens to finish
605            // first would disable the hook out from under a sibling still
606            // relying on it.
607            git::acquire_worktree_config(&repo).await?;
608            self.state.enabled_worktree_config = true;
609        }
610
611        for (index, (spec, label)) in self
612            .roles
613            .implementers
614            .clone()
615            .into_iter()
616            .zip(labels)
617            .enumerate()
618        {
619            let branch = self.state.branch_for(label);
620            let worktree = root.join(format!("cand-{label}"));
621            git::worktree_add_branch(&repo, &worktree, &branch, &base).await?;
622            if self.state.config.blind.commit_msg_hook {
623                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
624            }
625            git::local_exclude(&worktree, "/.magi/").await?;
626            self.state.candidates.push(Candidate {
627                index,
628                label,
629                agent: spec.id.clone(),
630                branch,
631                worktree,
632                summary: String::new(),
633                stat: String::new(),
634                files: 0,
635                commits: 0,
636                empty: false,
637                failed: None,
638                duration_ms: 0,
639                folded: false,
640            });
641        }
642
643        for j in 1..=self.roles.judges.len() {
644            let wt = root.join(format!("judge-{j}"));
645            if !wt.exists() {
646                git::worktree_add_detached(&repo, &wt, &base).await?;
647            }
648        }
649
650        // A judge cannot tell it is looking at its own patch — the seats keep
651        // separate conversations — but a panel that shares agents with the
652        // field is less independent than it looks, and that is worth saying out
653        // loud once per run rather than leaving it in the config.
654        let authors: Vec<&str> = self
655            .roles
656            .implementers
657            .iter()
658            .map(|a| a.id.as_str())
659            .collect();
660        let overlap: Vec<String> = self
661            .roles
662            .judges
663            .iter()
664            .enumerate()
665            .filter(|(_, j)| authors.contains(&j.id.as_str()))
666            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
667            .collect();
668        if !overlap.is_empty() {
669            let note = format!(
670                "{} also authored a candidate; blind, but the panel is less \
671                 independent than {} distinct agents would be",
672                overlap.join(", "),
673                self.roles.judges.len()
674            );
675            self.state.event("prep", note);
676        }
677
678        self.state.event(
679            "prep",
680            format!(
681                "{} candidates, {} judges, base {} ({})",
682                self.state.candidates.len(),
683                self.roles.judges.len(),
684                &self.state.base_commit[..7.min(self.state.base_commit.len())],
685                self.state.base_branch
686            ),
687        );
688        self.state.status = RunStatus::Implementing;
689        self.state.save()?;
690        Ok(())
691    }
692
693    // ----------------------------------------------------------- implement
694
695    async fn implement(&mut self) -> Result<()> {
696        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
697        // agent files with `magi task add` name the run that paid for it. The
698        // prompt overlay is cloned alongside it because the waves borrow it
699        // while `self` is mutably borrowed by the node's own bookkeeping.
700        let run_id = self.state.id.clone();
701        let prompts = self.state.config.prompts.clone();
702        let todo: Vec<usize> = self
703            .state
704            .candidates
705            .iter()
706            .enumerate()
707            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
708            .map(|(i, _)| i)
709            .collect();
710        if todo.is_empty() {
711            return self.after_implement();
712        }
713        self.state.status = RunStatus::Implementing;
714
715        let language = self.state.config.graph.language.clone();
716        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
717        let sessions = self.state.config.graph.sessions;
718        let artifacts = agent::artifacts_dir(&self.state.dir());
719
720        let mut jobs = Vec::new();
721        for &i in &todo {
722            let (index, label, worktree) = {
723                let c = &self.state.candidates[i];
724                (c.index, c.label, c.worktree.clone())
725            };
726            let spec = self.roles.implementers[index].clone();
727            let seat_key = format!("impl-{label}");
728            let seat = self.seat(&seat_key, &spec.id);
729            let instruction = self.state.instruction.clone();
730            jobs.push(SeatJob {
731                spec,
732                seat,
733                prompt: prompt::implement(&instruction, &worktree.to_string_lossy(), &language),
734                cwd: worktree,
735                timeout,
736                allow_write: true,
737                sessions,
738                artifacts: artifacts.clone(),
739                stem: format!("impl-{label}"),
740            });
741        }
742
743        self.state.event(
744            "implement",
745            format!("{} candidates in parallel", jobs.len()),
746        );
747        // Kept so a seat whose CLI hung up can be asked again from the same
748        // job: `wave` consumes what it is given.
749        let sent = jobs.clone();
750        let cache = self.state.config.cache_dir();
751        let ctx = WaveCtx {
752            run: &run_id,
753            node: "implement",
754            prompts: &prompts,
755            cache: cache.as_deref(),
756        };
757        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
758        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
759            .await;
760
761        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
762            let seat_key = seat.key.clone();
763            self.state.seats.insert(seat.key.clone(), seat);
764            let label = self.state.candidates[i].label;
765            let worktree = self.state.candidates[i].worktree.clone();
766            let base = self.state.base_commit.clone();
767
768            let (summary, duration, failed) = match out {
769                AgentOutcome::Ok(o) => {
770                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
771                    let failed = (!o.usable()).then(|| {
772                        if o.timed_out {
773                            "agent timed out".to_owned()
774                        } else {
775                            format!("agent exited with {:?}", o.exit_code)
776                        }
777                    });
778                    (text, o.duration_ms, failed)
779                }
780                // Left un-resumed by `resume_undelivered` (a dirty tree
781                // already rescues the work, or there was no session left to
782                // resume into) — reported like the ordinary failure it is,
783                // never as if `o.text` (the CLI's raw error JSON) were an
784                // answer.
785                AgentOutcome::Dropped(o) => {
786                    let why = o
787                        .dropped
788                        .as_ref()
789                        .map(|d| d.why.as_str())
790                        .unwrap_or("the CLI ended the stream without delivering its answer");
791                    (
792                        String::new(),
793                        o.duration_ms,
794                        Some(format!("the CLI dropped the stream ({why})")),
795                    )
796                }
797                AgentOutcome::Quota(o) => {
798                    self.state.quota.push(QuotaLoss {
799                        seat: seat_key,
800                        node: "implement".to_owned(),
801                        at: Timestamp::now(),
802                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
803                    });
804                    (
805                        String::new(),
806                        o.duration_ms,
807                        Some("rate limited (quota); produced no change".to_owned()),
808                    )
809                }
810                AgentOutcome::Failed(e) => (String::new(), 0, Some(e)),
811            };
812
813            // Rescue anything the agent edited but never committed: an
814            // uncommitted candidate would silently be an empty one.
815            let rescued = git::commit_all(
816                &worktree,
817                &format!("magi: candidate {label} (uncommitted work)"),
818            )
819            .await
820            .unwrap_or(false);
821            let commits = git::commits_ahead(&worktree, &base, "HEAD")
822                .await
823                .unwrap_or(0);
824            let patch = git::diff(&worktree, &base, "HEAD")
825                .await
826                .unwrap_or_default();
827            let stat = git::diff_stat(&worktree, &base, "HEAD")
828                .await
829                .unwrap_or_default();
830            let files = git::changed_files(&worktree, &base, "HEAD")
831                .await
832                .map(|f| f.len())
833                .unwrap_or(0);
834            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
835
836            let c = &mut self.state.candidates[i];
837            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
838            c.stat = stat;
839            c.files = files;
840            c.commits = commits;
841            c.duration_ms = duration;
842            c.empty = commits == 0 || patch.trim().is_empty();
843            // An agent that failed but still produced a committed change stays
844            // in the running: the patch is what gets judged, not the exit code.
845            c.failed = match failed {
846                Some(_) if c.empty => failed,
847                _ => None,
848            };
849            let note = match (&c.failed, c.empty, rescued) {
850                (Some(e), _, _) => format!("candidate {label}: {e}"),
851                (None, true, _) => format!("candidate {label}: no change produced"),
852                (None, false, true) => {
853                    format!(
854                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
855                    )
856                }
857                (None, false, false) => {
858                    format!("candidate {label}: {files} files, {commits} commits")
859                }
860            };
861            self.state.event("implement", note);
862            self.state.save()?;
863        }
864
865        self.after_implement()
866    }
867
868    /// Ask again, once, for work a CLI did and then failed to hand over.
869    ///
870    /// [`agent::dropped_stream`] recognises the one shape observed: an error
871    /// status with an empty response and a usage report showing output tokens,
872    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
873    /// seven minutes and 14,267 output tokens that arrived as an empty
874    /// candidate, because `agy`'s own subscriber fell behind and hung up.
875    ///
876    /// Two conditions, and both matter:
877    ///
878    /// - **Only when the tree is untouched.** Often the agent has already
879    ///   written its files and only the closing message was lost; the rescue
880    ///   commit below picks that up and there is nothing to ask for. Re-asking
881    ///   then would pay for a second implementation of work already on disk.
882    /// - **Once.** A CLI that drops one stream can drop the next, and this
883    ///   node is the most expensive in the graph.
884    ///
885    /// The re-ask is a resume, not a re-run: `has_context` is true because the
886    /// dropped reply still carried its `conversation_id`, so the seat is asked
887    /// to finish what it was doing rather than sent the whole task again. It
888    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
889    /// node's - for the same reason a re-ranked judge does: restating finished
890    /// work is not the work.
891    ///
892    /// Unlike a quota this is worth retrying at all: a rate limit fails the
893    /// same way until it resets, while an abandoned conversation is still
894    /// there to be picked up.
895    async fn resume_undelivered(
896        &mut self,
897        results: &mut [(usize, SeatState, AgentOutcome)],
898        sent: &[SeatJob],
899        prompts: &Prompts,
900        run_id: &str,
901    ) {
902        for (wi, seat, out) in results.iter_mut() {
903            let Some(dropped) = (match &*out {
904                AgentOutcome::Dropped(o) => o.dropped.clone(),
905                _ => None,
906            }) else {
907                continue;
908            };
909            let Some(job) = sent.get(*wi) else { continue };
910            // Already on disk? Then only the closing message was lost.
911            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
912                self.state.event(
913                    "implement",
914                    format!(
915                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
916                         work is in the tree",
917                        seat.key, dropped.output_tokens, dropped.why
918                    ),
919                );
920                continue;
921            }
922            // The re-ask only makes sense as a resume: `resume_after_drop`
923            // says nothing about the task, trusting the seat to still hold it.
924            // Without a session to resume — sessions disabled, or this CLI's
925            // drop shape happened not to carry a session id — that prompt
926            // would open a brand-new conversation with no context at all,
927            // which is worse than leaving this as the ordinary failure it
928            // already is.
929            if !has_context(&job.spec, seat, job.sessions) {
930                self.state.event(
931                    "implement",
932                    format!(
933                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
934                         is no session left to resume",
935                        seat.key, dropped.output_tokens, dropped.why
936                    ),
937                );
938                continue;
939            }
940            self.state.event(
941                "implement",
942                format!(
943                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
944                     conversation",
945                    seat.key, dropped.output_tokens, dropped.why
946                ),
947            );
948            let mut retry = job.clone();
949            retry.seat = seat.clone();
950            retry.prompt = prompt::resume_after_drop(&dropped.why);
951            retry.timeout = retry_budget(job.timeout, true);
952            retry.stem = format!("{}-resume", job.stem);
953            let cache = self.state.config.cache_dir();
954            let ctx = WaveCtx {
955                run: run_id,
956                node: "implement",
957                prompts,
958                cache: cache.as_deref(),
959            };
960            let (resumed_seat, resumed) =
961                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
962            *seat = resumed_seat;
963            *out = resumed;
964        }
965    }
966
967    fn after_implement(&mut self) -> Result<()> {
968        // Scan every candidate patch once the set is complete.
969        if self.state.leaks.is_empty() {
970            let cfg = self.state.config.blind.clone();
971            let mut leaks = Vec::new();
972            for c in &self.state.candidates {
973                let Some(patch) =
974                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
975                else {
976                    continue;
977                };
978                leaks.extend(blind::scan(
979                    &format!("candidate {} patch", c.label),
980                    &patch,
981                    &cfg.vendor_tokens,
982                ));
983            }
984            if !leaks.is_empty() {
985                let summary = leaks
986                    .iter()
987                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
988                    .collect::<Vec<_>>()
989                    .join(", ");
990                match cfg.on_leak {
991                    LeakPolicy::Fail => {
992                        self.state.status = RunStatus::Failed;
993                        self.state
994                            .event("blind", format!("vendor text in a patch: {summary}"));
995                        self.state.leaks = leaks;
996                        self.state.save()?;
997                        self.settle_questions();
998                        bail!(
999                            "blind.on_leak = \"fail\" and vendor text reached a \
1000                             judged patch: {summary}"
1001                        );
1002                    }
1003                    LeakPolicy::Redact => self.state.event(
1004                        "blind",
1005                        format!("redacting vendor text for judging: {summary}"),
1006                    ),
1007                    LeakPolicy::Warn => self.state.event(
1008                        "blind",
1009                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
1010                    ),
1011                }
1012                self.state.leaks = leaks;
1013            }
1014        }
1015
1016        if self.state.viable().is_empty() {
1017            self.state.status = RunStatus::Failed;
1018            self.state.save()?;
1019            self.settle_questions();
1020            bail!("no candidate produced a change; nothing to judge");
1021        }
1022        self.state.status = RunStatus::Judging;
1023        self.state.save()?;
1024        Ok(())
1025    }
1026
1027    // --------------------------------------------------------------- judge
1028
1029    async fn judge(&mut self) -> Result<()> {
1030        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1031        // agent files with `magi task add` name the run that paid for it. The
1032        // prompt overlay is cloned alongside it because the waves borrow it
1033        // while `self` is mutably borrowed by the node's own bookkeeping.
1034        let run_id = self.state.id.clone();
1035        let prompts = self.state.config.prompts.clone();
1036        if !self.state.judgements.is_empty() || self.state.judge_skipped {
1037            return Ok(());
1038        }
1039        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1040        if viable.len() == 1 {
1041            // Recorded so this is a one-time event: `judgements` stays empty
1042            // either way, which without this flag is indistinguishable from
1043            // "not yet judged" on the next reentry — and status is left
1044            // untouched, so a later node's conclusion (e.g. `Blocked` after
1045            // the review budget ran out) survives a resume instead of being
1046            // clobbered back to `Judging` by this node running again.
1047            self.state.judge_skipped = true;
1048            self.state.event(
1049                "judge",
1050                format!(
1051                    "only candidate {} produced a change; judging skipped",
1052                    viable[0].label
1053                ),
1054            );
1055            self.state.save()?;
1056            return Ok(());
1057        }
1058        self.state.status = RunStatus::Judging;
1059
1060        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1061        let language = self.state.config.graph.language.clone();
1062        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1063        let sessions = self.state.config.graph.sessions;
1064        let artifacts = agent::artifacts_dir(&self.state.dir());
1065        let root = self.state.worktree_root();
1066        let base_short = short(&self.state.base_commit);
1067
1068        let mut jobs = Vec::new();
1069        let mut orders = Vec::new();
1070        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1071            let order = blind::presentation_order(viable.len(), j, self.state.seed);
1072            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1073            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
1074            let seat_key = format!("judge-{}", j + 1);
1075            let seat = self.seat(&seat_key, &spec.id);
1076            jobs.push(SeatJob {
1077                prompt: prompt::judge(
1078                    &self.state.instruction,
1079                    &views,
1080                    self.roles.judges.len(),
1081                    &base_short,
1082                    &language,
1083                ),
1084                spec,
1085                seat,
1086                cwd: root.join(format!("judge-{}", j + 1)),
1087                timeout,
1088                allow_write: false,
1089                sessions,
1090                artifacts: artifacts.clone(),
1091                stem: format!("judge-{}", j + 1),
1092            });
1093        }
1094
1095        self.state.event(
1096            "judge",
1097            format!(
1098                "{} judges ranking {} candidates blind",
1099                jobs.len(),
1100                viable.len()
1101            ),
1102        );
1103        let labels_for_check = labels.clone();
1104        let mut quota_losses = Vec::new();
1105        let cache = self.state.config.cache_dir();
1106        let ctx = WaveCtx {
1107            run: &run_id,
1108            node: "judge",
1109            prompts: &prompts,
1110            cache: cache.as_deref(),
1111        };
1112        let results = ask_json_wave::<Ranking>(
1113            jobs,
1114            Arc::clone(&self.sem),
1115            self.state.config.graph.retries,
1116            &ctx,
1117            &mut quota_losses,
1118            &mut self.state,
1119            &move |r: &Ranking| r.validate(&labels_for_check),
1120        )
1121        .await;
1122        self.state.quota.extend(quota_losses);
1123
1124        for (j, (seat, res)) in results.into_iter().enumerate() {
1125            let agent_id = seat.agent.clone();
1126            self.state.seats.insert(seat.key.clone(), seat);
1127            let mut record = Judgement {
1128                judge: j + 1,
1129                seat: format!("judge-{}", j + 1),
1130                agent: agent_id,
1131                ranking: Vec::new(),
1132                reasons: BTreeMap::new(),
1133                confidence: None,
1134                order: orders[j].clone(),
1135                failed: None,
1136                duration_ms: 0,
1137            };
1138            match res {
1139                Ok((ranking, out)) => {
1140                    record.ranking = ranking.normalized();
1141                    record.reasons = ranking.reasons;
1142                    record.confidence = ranking.confidence;
1143                    record.duration_ms = out.duration_ms;
1144                    self.state.event(
1145                        "judge",
1146                        format!(
1147                            "judge {} ranked {}",
1148                            j + 1,
1149                            record.ranking.iter().collect::<String>()
1150                        ),
1151                    );
1152                }
1153                Err(e) => {
1154                    record.failed = Some(e.to_string());
1155                    self.state
1156                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
1157                }
1158            }
1159            self.state.judgements.push(record);
1160            self.state.save()?;
1161        }
1162        Ok(())
1163    }
1164
1165    // ---------------------------------------------------------- deliberate
1166
1167    async fn deliberate(&mut self) -> Result<()> {
1168        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1169        // agent files with `magi task add` name the run that paid for it. The
1170        // prompt overlay is cloned alongside it because the waves borrow it
1171        // while `self` is mutably borrowed by the node's own bookkeeping.
1172        let run_id = self.state.id.clone();
1173        let prompts = self.state.config.prompts.clone();
1174        if !self.state.deliberation.is_empty() {
1175            return Ok(());
1176        }
1177        let tops: Vec<char> = self
1178            .state
1179            .judgements
1180            .iter()
1181            .filter_map(|j| j.ranking.first().copied())
1182            .collect();
1183        let rounds = self.state.config.graph.deliberate_rounds;
1184        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
1185            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
1186                self.state.event(
1187                    "deliberate",
1188                    format!("judges agreed on {} outright; no deliberation", tops[0]),
1189                );
1190            }
1191            self.state.status = RunStatus::Voting;
1192            self.state.save()?;
1193            return Ok(());
1194        }
1195
1196        self.state.status = RunStatus::Deliberating;
1197        self.state.event(
1198            "deliberate",
1199            format!(
1200                "split: first choices were {} — opening {rounds} round(s)",
1201                tops.iter().collect::<String>()
1202            ),
1203        );
1204
1205        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1206        let language = self.state.config.graph.language.clone();
1207        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1208        let sessions = self.state.config.graph.sessions;
1209        let artifacts = agent::artifacts_dir(&self.state.dir());
1210        let root = self.state.worktree_root();
1211        let base_short = short(&self.state.base_commit);
1212
1213        // Judges argue in sequence so that a turn can answer the one before it;
1214        // that is the difference between deliberation and three parallel
1215        // monologues.
1216        for round in 1..=rounds {
1217            let mut turns: Vec<DeliberationTurn> = Vec::new();
1218            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1219                if self.state.judgements[j].failed.is_some() {
1220                    continue;
1221                }
1222                let seat_key = format!("judge-{}", j + 1);
1223                let mut seat = self.seat(&seat_key, &spec.id);
1224                let transcript = self.transcript(&turns, j);
1225                let context = if has_context(&spec, &seat, sessions) {
1226                    None
1227                } else {
1228                    Some(self.candidate_block(&viable, &base_short))
1229                };
1230                let text = prompt::deliberate(
1231                    &self.state.instruction,
1232                    context.as_deref(),
1233                    &transcript,
1234                    round,
1235                    rounds,
1236                    &language,
1237                );
1238                let job = SeatJob {
1239                    spec,
1240                    seat: seat.clone(),
1241                    prompt: text,
1242                    cwd: root.join(format!("judge-{}", j + 1)),
1243                    timeout,
1244                    allow_write: false,
1245                    sessions,
1246                    artifacts: artifacts.clone(),
1247                    stem: format!("delib-{round}-judge-{}", j + 1),
1248                };
1249                let cache = self.state.config.cache_dir();
1250                let ctx = WaveCtx {
1251                    run: &run_id,
1252                    node: "deliberate",
1253                    prompts: &prompts,
1254                    cache: cache.as_deref(),
1255                };
1256                let (updated, out) =
1257                    run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1258                seat = updated;
1259                let agent_id = seat.agent.clone();
1260                let seat_key = seat.key.clone();
1261                self.state.seats.insert(seat.key.clone(), seat);
1262                let body = match out {
1263                    AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
1264                    // Never read the CLI's raw error JSON as this judge's
1265                    // position — skip the seat instead, the same as any other
1266                    // failed turn.
1267                    AgentOutcome::Dropped(o) => {
1268                        let why =
1269                            o.dropped.as_ref().map(|d| d.why.as_str()).unwrap_or(
1270                                "the CLI ended the stream without delivering its answer",
1271                            );
1272                        self.state.event(
1273                            "deliberate",
1274                            format!(
1275                                "judge {} skipped: the CLI dropped the stream ({why})",
1276                                j + 1
1277                            ),
1278                        );
1279                        continue;
1280                    }
1281                    AgentOutcome::Quota(o) => {
1282                        self.state.quota.push(QuotaLoss {
1283                            seat: seat_key,
1284                            node: "deliberate".to_owned(),
1285                            at: Timestamp::now(),
1286                            reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1287                        });
1288                        self.state.event(
1289                            "deliberate",
1290                            format!("judge {} skipped: rate limited (quota)", j + 1),
1291                        );
1292                        continue;
1293                    }
1294                    AgentOutcome::Failed(e) => {
1295                        self.state
1296                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
1297                        continue;
1298                    }
1299                };
1300                let tentative = verdict::extract_json::<Position>(&body)
1301                    .ok()
1302                    .and_then(|p| p.tentative)
1303                    .and_then(|s| s.trim().chars().next())
1304                    .map(|c| c.to_ascii_uppercase());
1305                self.state.event(
1306                    "deliberate",
1307                    format!(
1308                        "round {round}: judge {} now favours {}",
1309                        j + 1,
1310                        tentative.map_or("—".to_owned(), |c| c.to_string())
1311                    ),
1312                );
1313                turns.push(DeliberationTurn {
1314                    judge: j + 1,
1315                    agent: agent_id,
1316                    body: blind::sanitize_prose(&body, &self.state.config.blind),
1317                    tentative,
1318                });
1319            }
1320            self.state
1321                .deliberation
1322                .push(DeliberationRound { round, turns });
1323            self.state.save()?;
1324        }
1325
1326        self.state.status = RunStatus::Voting;
1327        self.state.save()?;
1328        Ok(())
1329    }
1330
1331    // ---------------------------------------------------------------- vote
1332
1333    async fn vote(&mut self) -> Result<()> {
1334        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1335        // agent files with `magi task add` name the run that paid for it. The
1336        // prompt overlay is cloned alongside it because the waves borrow it
1337        // while `self` is mutably borrowed by the node's own bookkeeping.
1338        let run_id = self.state.id.clone();
1339        let prompts = self.state.config.prompts.clone();
1340        if !self.state.votes.is_empty() {
1341            return Ok(());
1342        }
1343        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1344        if viable.len() == 1 {
1345            return Ok(());
1346        }
1347        self.state.status = RunStatus::Voting;
1348
1349        let language = self.state.config.graph.language.clone();
1350        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1351        let sessions = self.state.config.graph.sessions;
1352        let artifacts = agent::artifacts_dir(&self.state.dir());
1353        let root = self.state.worktree_root();
1354        let base_short = short(&self.state.base_commit);
1355        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1356
1357        let mut jobs = Vec::new();
1358        let mut seats_at = Vec::new();
1359        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
1360            if self
1361                .state
1362                .judgements
1363                .get(j)
1364                .is_some_and(|r| r.failed.is_some())
1365            {
1366                continue;
1367            }
1368            let seat_key = format!("judge-{}", j + 1);
1369            let seat = self.seat(&seat_key, &spec.id);
1370            let mut text = prompt::final_vote(&viable, &language);
1371            if !has_context(&spec, &seat, sessions) {
1372                text = format!(
1373                    "{}\n\n# Candidates\n\n{}",
1374                    text,
1375                    self.candidate_block(&candidates, &base_short)
1376                );
1377            }
1378            jobs.push(SeatJob {
1379                spec,
1380                seat,
1381                prompt: text,
1382                cwd: root.join(format!("judge-{}", j + 1)),
1383                timeout,
1384                allow_write: false,
1385                sessions,
1386                artifacts: artifacts.clone(),
1387                stem: format!("vote-judge-{}", j + 1),
1388            });
1389            seats_at.push(j);
1390        }
1391
1392        self.state.event(
1393            "vote",
1394            format!(
1395                "collecting {} final votes one by one, privately",
1396                jobs.len()
1397            ),
1398        );
1399        let allowed = viable.clone();
1400        let mut quota_losses = Vec::new();
1401        let cache = self.state.config.cache_dir();
1402        let ctx = WaveCtx {
1403            run: &run_id,
1404            node: "vote",
1405            prompts: &prompts,
1406            cache: cache.as_deref(),
1407        };
1408        let results = ask_json_wave::<FinalVote>(
1409            jobs,
1410            Arc::clone(&self.sem),
1411            self.state.config.graph.retries,
1412            &ctx,
1413            &mut quota_losses,
1414            &mut self.state,
1415            &move |v: &FinalVote| match v.label() {
1416                Some(c) if allowed.contains(&c) => Ok(()),
1417                other => bail!("vote {other:?} is not one of {allowed:?}"),
1418            },
1419        )
1420        .await;
1421        self.state.quota.extend(quota_losses);
1422
1423        for (&j, (seat, res)) in seats_at.iter().zip(results) {
1424            let agent_id = seat.agent.clone();
1425            self.state.seats.insert(seat.key.clone(), seat);
1426            let initial = self
1427                .state
1428                .judgements
1429                .get(j)
1430                .and_then(|r| r.ranking.first().copied());
1431            let mut record = VoteRecord {
1432                judge: j + 1,
1433                agent: agent_id,
1434                vote: None,
1435                reason: String::new(),
1436                changed: false,
1437            };
1438            match res {
1439                Ok((v, _)) => {
1440                    record.vote = v.label();
1441                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1442                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
1443                    self.state.event(
1444                        "vote",
1445                        format!(
1446                            "judge {} voted {}{}",
1447                            j + 1,
1448                            record.vote.unwrap_or('?'),
1449                            if record.changed { " (changed)" } else { "" }
1450                        ),
1451                    );
1452                }
1453                Err(e) => {
1454                    self.state
1455                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
1456                }
1457            }
1458            self.state.votes.push(record);
1459            self.state.save()?;
1460        }
1461        Ok(())
1462    }
1463
1464    // --------------------------------------------------------------- tally
1465
1466    fn tally(&mut self) -> Result<()> {
1467        if self.state.tally.is_some() {
1468            return Ok(());
1469        }
1470        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
1471        let tops: Vec<char> = self
1472            .state
1473            .judgements
1474            .iter()
1475            .filter_map(|j| j.ranking.first().copied())
1476            .collect();
1477        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
1478
1479        // A judge whose private vote failed still counted once, in the initial
1480        // ranking; using it beats discarding a whole seat.
1481        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1482        let mut cast: Vec<char> = Vec::new();
1483        for (i, j) in self.state.judgements.iter().enumerate() {
1484            let vote = self
1485                .state
1486                .votes
1487                .iter()
1488                .find(|v| v.judge == i + 1)
1489                .and_then(|v| v.vote)
1490                .or_else(|| j.ranking.first().copied());
1491            if let Some(v) = vote {
1492                *first_choice.entry(v).or_insert(0) += 1;
1493                cast.push(v);
1494            }
1495        }
1496
1497        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
1498        for j in &self.state.judgements {
1499            let n = j.ranking.len();
1500            for (pos, label) in j.ranking.iter().enumerate() {
1501                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
1502            }
1503        }
1504
1505        let best = first_choice.values().copied().max().unwrap_or(0);
1506        let mut leaders: Vec<char> = first_choice
1507            .iter()
1508            .filter(|(_, v)| **v == best)
1509            .map(|(k, _)| *k)
1510            .collect();
1511        let mut tie_break = None;
1512        if leaders.len() > 1 {
1513            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
1514            let borda_leaders: Vec<char> = leaders
1515                .iter()
1516                .copied()
1517                .filter(|l| borda[l] == top_borda)
1518                .collect();
1519            tie_break = Some(if borda_leaders.len() == 1 {
1520                format!(
1521                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
1522                    leaders.len()
1523                )
1524            } else {
1525                format!(
1526                    "{} way tie on both first-choice votes and Borda points, broken by label order",
1527                    leaders.len()
1528                )
1529            });
1530            leaders = borda_leaders;
1531            leaders.sort_unstable();
1532        }
1533        let winner = *leaders
1534            .first()
1535            .or(viable.first())
1536            .context("no candidate to declare a winner from")?;
1537
1538        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
1539        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
1540        let deliberated = !self.state.deliberation.is_empty();
1541
1542        // Whose verdict is this? A rate-limited seat is absent even if it
1543        // ranked before the limit hit, so presence is measured against the
1544        // recorded losses, not just "did a ranking ever appear".
1545        let quota_seats: std::collections::BTreeSet<&str> =
1546            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1547        let mut present = 0usize;
1548        for (i, j) in self.state.judgements.iter().enumerate() {
1549            if quota_seats.contains(j.seat.as_str()) {
1550                continue;
1551            }
1552            let ranked = !j.ranking.is_empty() && j.failed.is_none();
1553            let voted = self
1554                .state
1555                .votes
1556                .iter()
1557                .any(|v| v.judge == i + 1 && v.vote.is_some());
1558            if ranked || voted {
1559                present += 1;
1560            }
1561        }
1562        // Strict majority of the configured panel. A bare majority is real
1563        // signal we can act on, while a minority verdict must never stand in
1564        // for a healthy one. A one-candidate run needs no panel at all, and
1565        // `judges` stays `0` rather than the roster size a panel that never
1566        // sat would otherwise be credited with.
1567        let needs_quorum = viable.len() > 1;
1568        let judges_total = if needs_quorum {
1569            self.roles.judges.len()
1570        } else {
1571            0
1572        };
1573        let quorum = if needs_quorum {
1574            judges_total / 2 + 1
1575        } else {
1576            0
1577        };
1578        let met_quorum = !needs_quorum || present >= quorum;
1579        let uncontested = (!needs_quorum).then(|| {
1580            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
1581        });
1582
1583        self.state.event(
1584            "tally",
1585            match &uncontested {
1586                Some(reason) => format!("winner {winner} — {reason}"),
1587                None => format!(
1588                    "winner {winner} — votes {} | initial {} | {} changed | \
1589                     {present}/{judges_total} judges{}",
1590                    first_choice
1591                        .iter()
1592                        .map(|(k, v)| format!("{k}:{v}"))
1593                        .collect::<Vec<_>>()
1594                        .join(" "),
1595                    if unanimous_initial {
1596                        "unanimous"
1597                    } else {
1598                        "split"
1599                    },
1600                    changed_votes,
1601                    if met_quorum {
1602                        String::new()
1603                    } else {
1604                        format!(" — below quorum ({quorum} required)")
1605                    },
1606                ),
1607            },
1608        );
1609        if !met_quorum {
1610            self.state.event(
1611                "stall",
1612                format!(
1613                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
1614                     the run stops here, resumable"
1615                ),
1616            );
1617        }
1618        self.state.tally = Some(Tally {
1619            first_choice,
1620            borda,
1621            winner,
1622            rankings: tops.len(),
1623            unanimous_initial,
1624            deliberated,
1625            changed_votes,
1626            unanimous_final,
1627            tie_break,
1628            judges: judges_total,
1629            present,
1630            quorum,
1631            met_quorum,
1632            uncontested,
1633        });
1634        self.state.status = if met_quorum {
1635            RunStatus::Reviewing
1636        } else {
1637            RunStatus::Stalled
1638        };
1639        self.state.save()?;
1640        Ok(())
1641    }
1642
1643    // ------------------------------------------------------------- recover
1644
1645    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
1646    /// resumed toward completion once the transient cause clears.
1647    ///
1648    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
1649    /// it toward the quorum, which is exactly the set of seats whose absence
1650    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
1651    /// not depend on which node happened to hit the limit), or an ordinary
1652    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
1653    /// seat is never disturbed.
1654    ///
1655    /// A seat that now answers with a usable ranking is "recovered": its
1656    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
1657    /// `tally` counts it present again), and its vote re-collected. A seat that
1658    /// still fails keeps its loss and stays absent.
1659    ///
1660    /// Returns `true` when the re-tally restores the quorum (the run may proceed
1661    /// to review/gate/merge), `false` when it is still below quorum (the run
1662    /// stays `Stalled`, still resumable for a later retry).
1663    #[allow(clippy::too_many_lines)]
1664    async fn recover_stall(&mut self) -> Result<bool> {
1665        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1666        // agent files with `magi task add` name the run that paid for it. The
1667        // prompt overlay is cloned alongside it because the waves borrow it
1668        // while `self` is mutably borrowed by the node's own bookkeeping.
1669        let run_id = self.state.id.clone();
1670        let prompts = self.state.config.prompts.clone();
1671        // Absent seats = quota-lost at any node, or failed outright. Mirroring
1672        // `tally`'s presence test (rather than the old quota-judge/vote filter)
1673        // is what keeps a non-quota collapse — or a quota loss recorded at the
1674        // deliberate node — from being a permanent dead-end on `--resume`.
1675        let quota_seats: BTreeSet<&str> =
1676            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
1677        let absent: Vec<String> = self
1678            .state
1679            .judgements
1680            .iter()
1681            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
1682            .map(|j| j.seat.clone())
1683            .collect();
1684        if absent.is_empty() {
1685            return Ok(false);
1686        }
1687        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
1688        if viable.len() <= 1 {
1689            return Ok(false);
1690        }
1691        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
1692        let language = self.state.config.graph.language.clone();
1693        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
1694        let sessions = self.state.config.graph.sessions;
1695        let artifacts = agent::artifacts_dir(&self.state.dir());
1696        let root = self.state.worktree_root();
1697        let base_short = short(&self.state.base_commit);
1698        let candidates: Vec<Candidate> = viable.clone();
1699
1700        // Map each absent seat key to its 0-based position in `roles.judges`.
1701        let mut positions: Vec<usize> = absent
1702            .iter()
1703            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
1704            .collect();
1705        if positions.is_empty() {
1706            return Ok(false);
1707        }
1708        positions.sort_unstable();
1709        positions.dedup();
1710
1711        // Re-rank the lost seats, one blind prompt each.
1712        let mut judge_jobs = Vec::new();
1713        for &j in &positions {
1714            let order = blind::presentation_order(viable.len(), j, self.state.seed);
1715            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
1716            let seat_key = format!("judge-{}", j + 1);
1717            let spec = self.roles.judges[j].clone();
1718            let seat = self.seat(&seat_key, &spec.id);
1719            judge_jobs.push(SeatJob {
1720                spec,
1721                seat,
1722                prompt: prompt::judge(
1723                    &self.state.instruction,
1724                    &views,
1725                    self.roles.judges.len(),
1726                    &base_short,
1727                    &language,
1728                ),
1729                cwd: root.join(seat_key),
1730                timeout,
1731                allow_write: false,
1732                sessions,
1733                artifacts: artifacts.clone(),
1734                stem: format!("judge-{}-recover", j + 1),
1735            });
1736        }
1737
1738        let labels_for_check = labels.clone();
1739        let mut judge_losses = Vec::new();
1740        let retries = self.state.config.graph.retries;
1741        let cache = self.state.config.cache_dir();
1742        let ctx = WaveCtx {
1743            run: &run_id,
1744            node: "judge",
1745            prompts: &prompts,
1746            cache: cache.as_deref(),
1747        };
1748        let results = ask_json_wave::<Ranking>(
1749            judge_jobs,
1750            Arc::clone(&self.sem),
1751            retries,
1752            &ctx,
1753            &mut judge_losses,
1754            &mut self.state,
1755            &move |r: &Ranking| r.validate(&labels_for_check),
1756        )
1757        .await;
1758
1759        // Refresh the judgement of every seat that ranked again.
1760        let mut recovered: BTreeSet<usize> = BTreeSet::new();
1761        for (&j, (seat, res)) in positions.iter().zip(results) {
1762            self.state.seats.insert(seat.key.clone(), seat);
1763            let record = &mut self.state.judgements[j];
1764            match res {
1765                Ok((ranking, out)) => {
1766                    record.ranking = ranking.normalized();
1767                    record.reasons = ranking.reasons;
1768                    record.confidence = ranking.confidence;
1769                    record.failed = None;
1770                    record.duration_ms = out.duration_ms;
1771                    recovered.insert(j);
1772                    self.state.event(
1773                        "recover",
1774                        format!("judge {} ranked again after the limit", j + 1),
1775                    );
1776                }
1777                Err(e) => {
1778                    self.state
1779                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
1780                }
1781            }
1782        }
1783
1784        // Re-ask the votes of the seats that recovered a ranking.
1785        let mut vote_jobs = Vec::new();
1786        let mut vote_pos: Vec<usize> = Vec::new();
1787        for &j in &recovered {
1788            let seat_key = format!("judge-{}", j + 1);
1789            let spec = self.roles.judges[j].clone();
1790            let seat = self.seat(&seat_key, &spec.id);
1791            let mut text = prompt::final_vote(&labels, &language);
1792            if !has_context(&spec, &seat, sessions) {
1793                text = format!(
1794                    "{}\n\n# Candidates\n\n{}",
1795                    text,
1796                    self.candidate_block(&candidates, &base_short)
1797                );
1798            }
1799            vote_jobs.push(SeatJob {
1800                spec,
1801                seat,
1802                prompt: text,
1803                cwd: root.join(seat_key),
1804                timeout,
1805                allow_write: false,
1806                sessions,
1807                artifacts: artifacts.clone(),
1808                stem: format!("vote-judge-{}-recover", j + 1),
1809            });
1810            vote_pos.push(j);
1811        }
1812        let allowed = labels.clone();
1813        let mut vote_losses = Vec::new();
1814        let vote_retries = self.state.config.graph.retries;
1815        let vote_cache = self.state.config.cache_dir();
1816        let ctx = WaveCtx {
1817            run: &run_id,
1818            node: "vote",
1819            prompts: &prompts,
1820            cache: vote_cache.as_deref(),
1821        };
1822        let votes = ask_json_wave::<FinalVote>(
1823            vote_jobs,
1824            Arc::clone(&self.sem),
1825            vote_retries,
1826            &ctx,
1827            &mut vote_losses,
1828            &mut self.state,
1829            &move |v: &FinalVote| match v.label() {
1830                Some(c) if allowed.contains(&c) => Ok(()),
1831                other => bail!("vote {other:?} is not one of {allowed:?}"),
1832            },
1833        )
1834        .await;
1835        for (&j, (seat, res)) in vote_pos.iter().zip(votes) {
1836            let agent_id = seat.agent.clone();
1837            self.state.seats.insert(seat.key.clone(), seat);
1838            match res {
1839                Ok((v, _)) => {
1840                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
1841                        rec.vote = v.label();
1842                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
1843                    } else {
1844                        self.state.votes.push(VoteRecord {
1845                            judge: j + 1,
1846                            agent: agent_id,
1847                            vote: v.label(),
1848                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
1849                            changed: false,
1850                        });
1851                    }
1852                    self.state.event(
1853                        "recover",
1854                        format!("judge {} voted again after the limit", j + 1),
1855                    );
1856                }
1857                Err(e) => {
1858                    self.state
1859                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
1860                }
1861            }
1862        }
1863
1864        // A seat that ranked again is present even if its re-vote failed —
1865        // `tally` falls back to the initial ranking's first choice — so clear
1866        // its quota loss. Seats that still fail keep theirs and stay absent.
1867        if !recovered.is_empty() {
1868            let recovered_keys: BTreeSet<String> = recovered
1869                .iter()
1870                .map(|&j| format!("judge-{}", j + 1))
1871                .collect();
1872            self.state
1873                .quota
1874                .retain(|q| !recovered_keys.contains(&q.seat));
1875        }
1876
1877        // Recompute the verdict from the refreshed panel.
1878        self.state.tally = None;
1879        self.tally()?;
1880        Ok(self
1881            .state
1882            .tally
1883            .as_ref()
1884            .map(|t| t.met_quorum)
1885            .unwrap_or(false))
1886    }
1887
1888    // ----------------------------------------------------------------- fold
1889
1890    async fn fold_losers(&mut self) -> Result<()> {
1891        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
1892            return Ok(());
1893        };
1894        let repo = self.state.repo.clone();
1895        let mut folded = Vec::new();
1896        for i in 0..self.state.candidates.len() {
1897            let c = &self.state.candidates[i];
1898            if c.label == winner || c.folded {
1899                continue;
1900            }
1901            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
1902            git::worktree_remove(&repo, &wt).await.ok();
1903            git::branch_delete(&repo, &branch).await.ok();
1904            self.state.candidates[i].folded = true;
1905            folded.push(label.to_string());
1906        }
1907        // The judges are finished; their checkouts are pure cost from here.
1908        let root = self.state.worktree_root();
1909        for j in 1..=self.roles.judges.len() {
1910            let wt = root.join(format!("judge-{j}"));
1911            if wt.exists() {
1912                git::worktree_remove(&repo, &wt).await.ok();
1913            }
1914        }
1915        if !folded.is_empty() {
1916            self.state
1917                .event("fold", format!("folded candidates {}", folded.join(", ")));
1918            self.state.save()?;
1919        }
1920        Ok(())
1921    }
1922
1923    // ------------------------------------------------------------ base sync
1924
1925    /// Land the winner's tree on the current tip of `<remote>/<base>` before
1926    /// anything verifies it.
1927    ///
1928    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
1929    /// read whatever is checked out in the winner's worktree. Left alone that
1930    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
1931    /// it when the run *branched* - and a run takes long enough that the base
1932    /// has usually moved by the time it gets here. A gate that ran there
1933    /// answers "green on the commit this run started from", not "green on
1934    /// what is about to land", and the difference showed up three times in
1935    /// one day as a green run whose merge would have reverted a file another
1936    /// pull request had already landed.
1937    ///
1938    /// Reuses [`git::rebase_branch_in_temp`] rather than a second
1939    /// implementation of the same idea: `land::Step::Rebase` already worked
1940    /// out the rules - throwaway worktree, conflict stops and reports rather
1941    /// than feeding a fixer, nothing runs in the primary tree - and a second
1942    /// rebase path is exactly the kind of drift `resolve_base`'s own doc
1943    /// warns about ("two answers to a question nobody notices until a diff is
1944    /// wrong").
1945    ///
1946    /// Bounded by [`BASE_SYNC_ROUNDS`], counted in `state.base_sync.attempts`
1947    /// so it survives a park/resume. A conflict or a push failure sets
1948    /// `state.base_sync.conflict` and leaves the branch and worktree exactly
1949    /// as they were - untouched, for a person to look at - which is also what
1950    /// makes re-entering this function afterwards a no-op instead of a second
1951    /// attempt at the same wall.
1952    async fn sync_to_base(&mut self) -> Result<()> {
1953        if self
1954            .state
1955            .base_sync
1956            .as_ref()
1957            .is_some_and(|s| s.conflict.is_some())
1958        {
1959            return Ok(());
1960        }
1961        let Some(winner) = self.state.winner().cloned() else {
1962            return Ok(());
1963        };
1964
1965        let repo = self.state.repo.clone();
1966        let remote = self.state.config.merge.remote.clone();
1967        let base_branch = self.state.base_branch.clone();
1968        let tracking = format!("{remote}/{base_branch}");
1969
1970        git::fetch(&repo, &remote, &base_branch).await.ok();
1971        // No network, or the remote never had this branch: `resolve_base`
1972        // already treats that as non-fatal at branch time, and a run that got
1973        // this far must not be blocked by it here either.
1974        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
1975            return Ok(());
1976        };
1977
1978        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
1979        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
1980        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
1981
1982        if behind == 0 {
1983            self.state.base_sync = Some(BaseSync {
1984                tip,
1985                behind: 0,
1986                attempts,
1987                conflict: None,
1988            });
1989            self.state.save()?;
1990            return Ok(());
1991        }
1992
1993        if attempts >= BASE_SYNC_ROUNDS {
1994            let why = format!(
1995                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
1996                 rebase(s); rebasing again would only race it",
1997                winner.branch
1998            );
1999            self.state.status = RunStatus::Blocked;
2000            self.state.base_sync = Some(BaseSync {
2001                tip,
2002                behind,
2003                attempts,
2004                conflict: Some(why.clone()),
2005            });
2006            self.state.event("land", why);
2007            self.state.save()?;
2008            return Ok(());
2009        }
2010
2011        self.state.event(
2012            "land",
2013            format!(
2014                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
2015                winner.branch
2016            ),
2017        );
2018        self.state.save()?;
2019
2020        let scratch = self.state.dir().join("base-sync");
2021        let rebased = git::rebase_branch_in_temp(&repo, &scratch, &winner.branch, &tracking).await;
2022        let attempts = attempts + 1;
2023        match rebased {
2024            Ok(None) => {
2025                // The branch ref moved, but a worktree that already had it
2026                // checked out (the winner's) was not told; sync its index and
2027                // files before anything reads them.
2028                git::sync_to_head(&winner.worktree).await?;
2029                self.state.base_sync = Some(BaseSync {
2030                    tip: tip.clone(),
2031                    behind: 0,
2032                    attempts,
2033                    conflict: None,
2034                });
2035                self.state
2036                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
2037            }
2038            Ok(Some(conflict)) => {
2039                let why = format!(
2040                    "{} conflicts with {tracking} and did not rebase: {}",
2041                    winner.branch,
2042                    conflict.chars().take(600).collect::<String>()
2043                );
2044                self.state.status = RunStatus::Blocked;
2045                self.state.base_sync = Some(BaseSync {
2046                    tip,
2047                    behind,
2048                    attempts,
2049                    conflict: Some(why.clone()),
2050                });
2051                self.state.event("land", why);
2052            }
2053            Err(e) => {
2054                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
2055                self.state.status = RunStatus::Blocked;
2056                self.state.base_sync = Some(BaseSync {
2057                    tip,
2058                    behind,
2059                    attempts,
2060                    conflict: Some(why.clone()),
2061                });
2062                self.state.event("land", why);
2063            }
2064        }
2065        self.state.save()?;
2066        Ok(())
2067    }
2068
2069    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
2070    /// last landed the winner on, once it has run, else the commit the run
2071    /// branched from.
2072    ///
2073    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
2074    /// and `vote` all happen before there is a winner to rebase, so they
2075    /// compare every candidate against the branch point on purpose, and a
2076    /// base that moves after they are already done cannot change an answer
2077    /// they already gave.
2078    fn landing_base(&self) -> String {
2079        self.state
2080            .base_sync
2081            .as_ref()
2082            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
2083    }
2084
2085    // --------------------------------------------------------------- review
2086
2087    async fn review_loop(&mut self) -> Result<()> {
2088        // A base that would not rebase is a person's decision, not a review
2089        // round: nothing here would change the answer, and reviewers and a
2090        // fixer would be spending real budget on a tree that cannot land
2091        // regardless of what they find.
2092        if self
2093            .state
2094            .base_sync
2095            .as_ref()
2096            .is_some_and(|s| s.conflict.is_some())
2097        {
2098            return Ok(());
2099        }
2100        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2101        // agent files with `magi task add` name the run that paid for it. The
2102        // prompt overlay is cloned alongside it because the waves borrow it
2103        // while `self` is mutably borrowed by the node's own bookkeeping.
2104        let run_id = self.state.id.clone();
2105        let prompts = self.state.config.prompts.clone();
2106        let Some(winner) = self.state.winner().cloned() else {
2107            return Ok(());
2108        };
2109        let max_rounds = self.state.config.graph.review_rounds;
2110        // A clean round, an exhausted round budget, or a stalled tree (see
2111        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
2112        // they are recorded — recomputed here, not read off `status`, so a
2113        // reentry into a run that already stopped restates the identical
2114        // verdict instead of silently handing back whatever an earlier node
2115        // in this same walk clobbered `status` to (a solo-candidate
2116        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
2117        // below runs an empty range once the budget is spent, and would
2118        // otherwise fall through without touching `status` at all.
2119        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
2120            self.state.status = status;
2121            self.state.save()?;
2122            return Ok(());
2123        }
2124        self.state.status = RunStatus::Reviewing;
2125
2126        let repo = self.state.repo.clone();
2127        let root = self.state.worktree_root();
2128        let language = self.state.config.graph.language.clone();
2129        let sessions = self.state.config.graph.sessions;
2130        let artifacts = agent::artifacts_dir(&self.state.dir());
2131        let base = self.landing_base();
2132        let base_short = short(&base);
2133        let reviewers = self.roles.reviewers.clone();
2134        let shell = self.state.config.shell();
2135
2136        let mut prev_e2e: Option<String> = None;
2137        for round in (self.state.reviews.len() + 1)..=max_rounds {
2138            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
2139            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
2140            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
2141
2142            // Each reviewer gets its own detached checkout of exactly this
2143            // commit: nobody can perturb the winner's tree, and the fixer can
2144            // keep working without racing a reviewer.
2145            let mut jobs = Vec::new();
2146            for (r, spec) in reviewers.iter().cloned().enumerate() {
2147                let wt = root.join(format!("review-{}", r + 1));
2148                if wt.exists() {
2149                    git::reset_detached(&wt, &head).await?;
2150                } else {
2151                    git::worktree_add_detached(&repo, &wt, &head).await?;
2152                }
2153                let seat_key = format!("review-{}", r + 1);
2154                let seat = self.seat(&seat_key, &spec.id);
2155                jobs.push(SeatJob {
2156                    prompt: prompt::review(&prompt::ReviewCtx {
2157                        instruction: &self.state.instruction,
2158                        branch: &winner.branch,
2159                        base_short: &base_short,
2160                        stat: &stat,
2161                        patch: &patch,
2162                        e2e: prev_e2e.as_deref(),
2163                        reviewers: reviewers.len(),
2164                        round,
2165                        rounds: max_rounds,
2166                        // A review-only run has no rankings, so nothing
2167                        // competed for this patch and the reviewer is told so.
2168                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
2169                        lens: Lens::for_seat(r),
2170                        language: &language,
2171                    }),
2172                    spec,
2173                    seat,
2174                    cwd: wt,
2175                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
2176                    allow_write: false,
2177                    sessions,
2178                    artifacts: artifacts.clone(),
2179                    stem: format!("review-{round}-{}", r + 1),
2180                });
2181            }
2182
2183            self.state.event(
2184                "review",
2185                format!(
2186                    "round {round}: {} reviewers on {}",
2187                    jobs.len(),
2188                    short(&head)
2189                ),
2190            );
2191            let mut quota_losses = Vec::new();
2192            let review_retries = self.state.config.graph.retries;
2193            let review_cache = self.state.config.cache_dir();
2194            let ctx = WaveCtx {
2195                run: &run_id,
2196                node: "review",
2197                prompts: &prompts,
2198                cache: review_cache.as_deref(),
2199            };
2200            let results = ask_json_wave::<Review>(
2201                jobs,
2202                Arc::clone(&self.sem),
2203                review_retries,
2204                &ctx,
2205                &mut quota_losses,
2206                &mut self.state,
2207                &|_: &Review| Ok(()),
2208            )
2209            .await;
2210            // Counted before the move below: how many of *this* round's
2211            // reviewer seats were lost to their own rate limit, as opposed to
2212            // a crash, a timeout, or unparsable output — see `round_is_clean`.
2213            let round_quota_missing = quota_losses.len();
2214            self.state.quota.extend(quota_losses);
2215
2216            let mut records = Vec::new();
2217            let mut all_findings = Vec::new();
2218            for (r, (seat, res)) in results.into_iter().enumerate() {
2219                let agent_id = seat.agent.clone();
2220                self.state.seats.insert(seat.key.clone(), seat);
2221                let mut record = ReviewRecord {
2222                    reviewer: r + 1,
2223                    agent: agent_id,
2224                    summary: String::new(),
2225                    findings: Vec::new(),
2226                    vote: None,
2227                    failed: None,
2228                    duration_ms: 0,
2229                };
2230                match res {
2231                    Ok((review, out)) => {
2232                        // Sanitized here, at the point every other piece of
2233                        // agent prose in this file is (candidate summaries,
2234                        // deliberation turns, vote reasons): a reviewer's own
2235                        // words are the one thing about it that could name
2236                        // it, and reconsideration below broadcasts this same
2237                        // summary and these same findings to every other
2238                        // seat on the panel.
2239                        record.summary =
2240                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
2241                        record.vote = Some(review.vote);
2242                        record.duration_ms = out.duration_ms;
2243                        for (n, mut f) in review.findings.into_iter().enumerate() {
2244                            // ids are magi's, never the agent's: the fixer's
2245                            // adoption report is keyed by them.
2246                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
2247                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
2248                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
2249                            // `file` is agent-supplied prose too, never
2250                            // checked against the real tree — the same
2251                            // exposure `title`/`detail` above have, just in
2252                            // a field easy to forget because it looks like a
2253                            // path rather than free text.
2254                            f.file = f
2255                                .file
2256                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
2257                            all_findings.push(f.clone());
2258                            record.findings.push(f);
2259                        }
2260                        self.state.event(
2261                            "review",
2262                            format!(
2263                                "round {round}: reviewer {} voted {} with {} finding(s)",
2264                                r + 1,
2265                                review.vote.label(),
2266                                record.findings.len()
2267                            ),
2268                        );
2269                    }
2270                    Err(e) => {
2271                        record.failed = Some(e.to_string());
2272                        self.state.event(
2273                            "review",
2274                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
2275                        );
2276                    }
2277                }
2278                records.push(record);
2279            }
2280
2281            // Tally the round's votes and, if they split, spend the one
2282            // round of reconsideration the split -> deliberate -> revote
2283            // shape `judge`/`vote` use for the panel, sized down to what a
2284            // read-only review round can afford: one round, and a revote
2285            // rather than an argument, because the panel already wrote its
2286            // reasoning down as findings the first time around.
2287            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
2288            let vote_split =
2289                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
2290            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
2291            if vote_split {
2292                self.state.event(
2293                    "review",
2294                    format!(
2295                        "round {round}: votes split ({}) — one round of reconsideration",
2296                        initial_votes
2297                            .iter()
2298                            .map(|v| v.label())
2299                            .collect::<Vec<_>>()
2300                            .join(", ")
2301                    ),
2302                );
2303                // Seats read every seat's findings and votes, still numbered
2304                // and never named — the same anonymity `review` itself keeps.
2305                let panel: Vec<ReviewSeatReport<'_>> = records
2306                    .iter()
2307                    .filter_map(|r| {
2308                        r.vote.map(|vote| ReviewSeatReport {
2309                            reviewer: r.reviewer,
2310                            vote,
2311                            summary: &r.summary,
2312                            findings: &r.findings,
2313                        })
2314                    })
2315                    .collect();
2316
2317                let mut jobs = Vec::new();
2318                let mut seats_at = Vec::new();
2319                for (r, spec) in reviewers.iter().cloned().enumerate() {
2320                    // A seat with no initial vote has nothing to reconsider
2321                    // from and stays absent, the same as it stayed absent
2322                    // from `panel` above.
2323                    if records[r].vote.is_none() {
2324                        continue;
2325                    }
2326                    let wt = root.join(format!("review-{}", r + 1));
2327                    let seat_key = format!("review-{}", r + 1);
2328                    let seat = self.seat(&seat_key, &spec.id);
2329                    // A seat with no live session has already forgotten the
2330                    // initial review's prompt — restate the patch it is
2331                    // voting on, the same as `deliberate`/`vote` do for a
2332                    // judge in the same position.
2333                    let patch_ctx = if has_context(&spec, &seat, sessions) {
2334                        None
2335                    } else {
2336                        Some(ReviewPatch {
2337                            branch: &winner.branch,
2338                            base_short: &base_short,
2339                            stat: &stat,
2340                            patch: &patch,
2341                        })
2342                    };
2343                    let prompt = prompt::review_reconsider(&ReviewReconsiderCtx {
2344                        instruction: &self.state.instruction,
2345                        reviewer: r + 1,
2346                        lens: Lens::for_seat(r),
2347                        panel: &panel,
2348                        patch: patch_ctx,
2349                        round,
2350                        rounds: max_rounds,
2351                        language: &language,
2352                    });
2353                    jobs.push(SeatJob {
2354                        prompt,
2355                        spec,
2356                        seat,
2357                        cwd: wt,
2358                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
2359                        allow_write: false,
2360                        sessions,
2361                        artifacts: artifacts.clone(),
2362                        stem: format!("review-{round}-reconsider-{}", r + 1),
2363                    });
2364                    seats_at.push(r);
2365                }
2366
2367                let mut recon_quota_losses = Vec::new();
2368                let recon_cache = self.state.config.cache_dir();
2369                let recon_ctx = WaveCtx {
2370                    run: &run_id,
2371                    node: "review",
2372                    prompts: &prompts,
2373                    cache: recon_cache.as_deref(),
2374                };
2375                let recon_results = ask_json_wave::<ReviewRevote>(
2376                    jobs,
2377                    Arc::clone(&self.sem),
2378                    review_retries,
2379                    &recon_ctx,
2380                    &mut recon_quota_losses,
2381                    &mut self.state,
2382                    &|_: &ReviewRevote| Ok(()),
2383                )
2384                .await;
2385                self.state.quota.extend(recon_quota_losses);
2386
2387                for (&r, (seat, res)) in seats_at.iter().zip(recon_results) {
2388                    let agent_id = seat.agent.clone();
2389                    self.state.seats.insert(seat.key.clone(), seat);
2390                    let mut rec = ReviewRevoteRecord {
2391                        reviewer: r + 1,
2392                        agent: agent_id,
2393                        vote: None,
2394                        reason: String::new(),
2395                        failed: None,
2396                    };
2397                    match res {
2398                        Ok((rv, _)) => {
2399                            rec.vote = Some(rv.vote);
2400                            rec.reason =
2401                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
2402                            self.state.event(
2403                                "review",
2404                                format!(
2405                                    "round {round}: reviewer {} revoted {}",
2406                                    r + 1,
2407                                    rv.vote.label()
2408                                ),
2409                            );
2410                        }
2411                        Err(e) => {
2412                            rec.failed = Some(e.to_string());
2413                            self.state.event(
2414                                "review",
2415                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
2416                            );
2417                        }
2418                    }
2419                    reconsideration.push(rec);
2420                }
2421            } else if initial_votes.len() > 1 {
2422                self.state.event(
2423                    "review",
2424                    format!(
2425                        "round {round}: votes agreed ({}) — no reconsideration",
2426                        initial_votes[0].label()
2427                    ),
2428                );
2429            }
2430
2431            // The final vote per seat is its revote where reconsideration
2432            // ran and answered, its initial vote otherwise — the same
2433            // fallback `tally` uses for a judge whose private vote failed.
2434            let final_votes: Vec<ReviewVote> = records
2435                .iter()
2436                .filter_map(|r| {
2437                    reconsideration
2438                        .iter()
2439                        .find(|rv| rv.reviewer == r.reviewer)
2440                        .and_then(|rv| rv.vote)
2441                        .or(r.vote)
2442                })
2443                .collect();
2444            let round_verdict = ReviewVote::worst(final_votes);
2445
2446            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
2447            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
2448            // A round that already has a blocking finding and a round left to
2449            // try is going back to the fixer no matter what `verify.e2e`
2450            // says, so running it first only spends the loop's slowest step
2451            // (minutes, for a Rust repo's full test suite) on a head about
2452            // to be rewritten. Deferred, never skipped: `verify.e2e` still
2453            // runs once a round has no blocking findings left (see
2454            // `round_is_clean`, which a deferred — empty — `e2e` can never
2455            // satisfy since `blocking` is nonzero whenever this branch is
2456            // taken), and `stop_reviewing` forces a real run before it will
2457            // ever read a deferred round as green.
2458            let defer_e2e =
2459                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
2460            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
2461                let reason =
2462                    format!("{blocking} blocking finding(s) already required a fix this round");
2463                self.state.event(
2464                    "verify",
2465                    format!(
2466                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
2467                         {}); it will run once a round has none left",
2468                        short(&head)
2469                    ),
2470                );
2471                (Vec::new(), false, true, Some(reason))
2472            } else {
2473                let e2e_commands = self.state.config.verify.e2e.clone();
2474                let (e2e, verify_retried) = run_e2e_with_retry(
2475                    &mut self.state,
2476                    &shell,
2477                    &e2e_commands,
2478                    &winner.worktree,
2479                    verify_timeout,
2480                    &format!("round {round}"),
2481                )
2482                .await;
2483                (e2e, verify_retried, false, None)
2484            };
2485
2486            let e2e_failures: String = e2e
2487                .iter()
2488                .filter(|o| !o.ok())
2489                .map(|o| format!("$ {}\n{}\n", o.command, o.output_tail))
2490                .collect();
2491
2492            let expected = records.len();
2493            let answered = records.iter().filter(|r| r.failed.is_none()).count();
2494            let incomplete = answered < expected;
2495            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
2496            let policy = self.state.config.graph.incomplete_review;
2497            let clean = round_is_clean(
2498                blocking,
2499                e2e_ok,
2500                answered,
2501                expected,
2502                round_quota_missing,
2503                policy,
2504            );
2505
2506            let mut round_record = ReviewRound {
2507                round,
2508                head: head.clone(),
2509                verified_head: None,
2510                reviews: records,
2511                e2e,
2512                verify_retried,
2513                e2e_deferred,
2514                e2e_defer_reason,
2515                fix: None,
2516                blocking,
2517                answered,
2518                expected,
2519                clean,
2520                progressed: false,
2521                vote_split,
2522                reconsideration,
2523                verdict: round_verdict,
2524            };
2525
2526            if incomplete {
2527                let missing: Vec<String> = round_record
2528                    .reviews
2529                    .iter()
2530                    .filter(|r| r.failed.is_some())
2531                    .map(|r| format!("review-{}", r.reviewer))
2532                    .collect();
2533                self.state.event(
2534                    "review",
2535                    format!(
2536                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
2537                        missing.join(", ")
2538                    ),
2539                );
2540            }
2541
2542            if clean {
2543                self.state.event(
2544                    "review",
2545                    if incomplete && policy == IncompleteReviewPolicy::Warn {
2546                        format!(
2547                            "round {round}: clean (warn policy, incomplete panel) — no \
2548                             blocking findings from the seats that answered, verification green"
2549                        )
2550                    } else if incomplete {
2551                        format!(
2552                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
2553                             quorum) — no blocking findings from the seats that answered, \
2554                             verification green",
2555                            expected - answered
2556                        )
2557                    } else {
2558                        format!("round {round}: clean — no blocking findings, verification green")
2559                    },
2560                );
2561                self.state.reviews.push(round_record);
2562                self.state.status = RunStatus::Gating;
2563                self.state.save()?;
2564                return Ok(());
2565            }
2566
2567            // Nothing was raised and verification passed, but not every seat
2568            // answered and `round_is_clean` still refused to call it clean —
2569            // either a seat is missing for a reason other than its own quota
2570            // (a crash, a timeout, unparsable output — worth another try), or
2571            // every seat that could have answered lost its quota and nobody
2572            // is left to decide on: re-review rather than send the fixer
2573            // after a round with nothing to fix.
2574            if incomplete && blocking == 0 && e2e_ok {
2575                self.state.reviews.push(round_record);
2576                self.state.save()?;
2577                if round == max_rounds {
2578                    self.state.status = RunStatus::Blocked;
2579                    self.state.event(
2580                        "review",
2581                        format!(
2582                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
2583                             refusing to call it clean",
2584                            expected - answered
2585                        ),
2586                    );
2587                    return Ok(());
2588                }
2589                prev_e2e = None;
2590                continue;
2591            }
2592
2593            if round == max_rounds {
2594                self.state.reviews.push(round_record);
2595                return self
2596                    .stop_reviewing(
2597                        &format!(
2598                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
2599                        ),
2600                        &shell,
2601                        &winner.worktree,
2602                    )
2603                    .await;
2604            }
2605
2606            // Fix. The winner's own implementer seat continues its conversation:
2607            // the competition is over, so context is pure benefit now.
2608            let (fix_spec, fix_seat_key) = match &self.roles.fixer {
2609                Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
2610                _ => (
2611                    self.state
2612                        .config
2613                        .agent(&winner.agent)
2614                        .cloned()
2615                        .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
2616                    format!("impl-{}", winner.label),
2617                ),
2618            };
2619            let seat = self.seat(&fix_seat_key, &fix_spec.id);
2620            let blocking_findings: Vec<_> = all_findings
2621                .iter()
2622                .filter(|f| f.severity.blocks())
2623                .cloned()
2624                .collect();
2625            let job = SeatJob {
2626                prompt: prompt::fix(
2627                    &self.state.instruction,
2628                    &blocking_findings,
2629                    (!e2e_failures.is_empty()).then_some(e2e_failures.as_str()),
2630                    e2e_deferred,
2631                    round,
2632                    max_rounds,
2633                    &language,
2634                ),
2635                spec: fix_spec.clone(),
2636                seat,
2637                cwd: winner.worktree.clone(),
2638                timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
2639                allow_write: true,
2640                sessions,
2641                artifacts: artifacts.clone(),
2642                stem: format!("fix-{round}"),
2643            };
2644            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
2645            let cache = self.state.config.cache_dir();
2646            let ctx = WaveCtx {
2647                run: &run_id,
2648                node: "fix",
2649                prompts: &prompts,
2650                cache: cache.as_deref(),
2651            };
2652            let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2653            let agent_id = seat.agent.clone();
2654            let seat_key = seat.key.clone();
2655            self.state.seats.insert(seat.key.clone(), seat);
2656
2657            let mut fix = FixRecord {
2658                agent: agent_id,
2659                addressed: Vec::new(),
2660                rejected: Vec::new(),
2661                notes: String::new(),
2662                committed: false,
2663                failed: None,
2664                duration_ms: 0,
2665            };
2666            match out {
2667                AgentOutcome::Ok(o) => {
2668                    fix.duration_ms = o.duration_ms;
2669                    match verdict::extract_json::<FixReport>(&o.text) {
2670                        Ok(report) => {
2671                            fix.addressed = report.addressed;
2672                            fix.rejected = report.rejected;
2673                            fix.notes =
2674                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
2675                        }
2676                        Err(e) => fix.failed = Some(format!("unparsable fix report: {e}")),
2677                    }
2678                }
2679                // The CLI's raw error JSON is not a fix report to parse.
2680                AgentOutcome::Dropped(o) => {
2681                    fix.duration_ms = o.duration_ms;
2682                    let why = o
2683                        .dropped
2684                        .as_ref()
2685                        .map(|d| d.why.as_str())
2686                        .unwrap_or("the CLI ended the stream without delivering its answer");
2687                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
2688                }
2689                AgentOutcome::Quota(o) => {
2690                    self.state.quota.push(QuotaLoss {
2691                        seat: seat_key,
2692                        node: "fix".to_owned(),
2693                        at: Timestamp::now(),
2694                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2695                    });
2696                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
2697                }
2698                AgentOutcome::Failed(e) => fix.failed = Some(e),
2699            }
2700            git::commit_all(
2701                &winner.worktree,
2702                &format!("magi: review round {round} fixes (uncommitted work)"),
2703            )
2704            .await
2705            .ok();
2706            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
2707            fix.committed = after != before;
2708            // Judged by what `git` says moved against base, never by the
2709            // fixer's own `addressed`/`rejected` count — see
2710            // `ReviewRound::progressed`. Propagated with `?`, the same as the
2711            // `patch` snapshot above: swallowing this error would default
2712            // `diff_after` to empty, which almost always differs from a
2713            // non-empty `patch` and reads as "progressed" — exactly backwards
2714            // for a `git` failure the stagnation check cannot see through.
2715            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
2716            let progressed = diff_after != patch;
2717            let commit_note = if fix.committed {
2718                "committed"
2719            } else {
2720                "NO new commit"
2721            };
2722            let tree_note = if progressed {
2723                "changed vs base"
2724            } else {
2725                "unchanged vs base"
2726            };
2727            self.state.event(
2728                "fix",
2729                match &fix.failed {
2730                    // Distinct on purpose from "0 addressed, 0 rejected": the
2731                    // fixer's own diff still landed (blocking counts do keep
2732                    // falling round over round), only its adoption report did
2733                    // not come back, so this must never read like every
2734                    // finding was reviewed and declined.
2735                    Some(reason) => {
2736                        format!(
2737                            "round {round}: fixer's adoption report was lost ({reason}); \
2738                             {commit_note}, tree {tree_note}"
2739                        )
2740                    }
2741                    None => format!(
2742                        "round {round}: {} addressed, {} rejected, {commit_note}, tree {tree_note}",
2743                        fix.addressed.len(),
2744                        fix.rejected.len(),
2745                    ),
2746                },
2747            );
2748            round_record.fix = Some(fix);
2749            round_record.progressed = progressed;
2750            self.state.reviews.push(round_record);
2751            self.state.save()?;
2752
2753            prev_e2e = (!e2e_failures.is_empty()).then_some(e2e_failures);
2754
2755            let streak = self
2756                .state
2757                .reviews
2758                .iter()
2759                .rev()
2760                .take_while(|r| !r.progressed)
2761                .count();
2762            if streak >= STAGNANT_LIMIT {
2763                return self
2764                    .stop_reviewing(
2765                        &format!(
2766                            "the tree has not moved against base for {streak} round(s) in a row"
2767                        ),
2768                        &shell,
2769                        &winner.worktree,
2770                    )
2771                    .await;
2772            }
2773        }
2774        Ok(())
2775    }
2776
2777    /// Decide, from the last recorded round's own verification, whether
2778    /// stopping the review loop is a hand-off or a genuine block.
2779    ///
2780    /// Called once the loop has given up trying — the round budget is spent,
2781    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
2782    /// findings still open, never while a round is still clean or the
2783    /// incomplete-panel case handled inline above. Gate and e2e are facts
2784    /// about the tree; a lingering review finding is an opinion, and this
2785    /// workload's own `magi stats` puts reviewer precision low enough
2786    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
2787    /// must not by itself stand between a green, verified change and the
2788    /// human who decides what to do with it. A red e2e is not an opinion, so
2789    /// that case still blocks, with the failing command and a tail of its
2790    /// output recorded here rather than left in `run.json` for someone to go
2791    /// find.
2792    ///
2793    /// A round that deferred its own e2e (see [`Config::graph`]'s
2794    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
2795    /// only because nothing ran, and treating an empty list as a passing one
2796    /// here is exactly the "deferred painted green" bug this function exists
2797    /// to not have. When the last round deferred, this makes the real run —
2798    /// on the actual worktree this loop is about to stop touching — before
2799    /// deciding anything.
2800    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
2801        let round_idx = self.state.reviews.len() - 1;
2802        let needs_catchup_run = {
2803            let last = &self.state.reviews[round_idx];
2804            last.e2e.is_empty() && last.e2e_deferred
2805        };
2806        if needs_catchup_run {
2807            let round = self.state.reviews[round_idx].round;
2808            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
2809            let commands = self.state.config.verify.e2e.clone();
2810            let verified_head = git::rev_parse(worktree, "HEAD").await?;
2811            let (outcomes, verify_retried) = run_e2e_with_retry(
2812                &mut self.state,
2813                shell,
2814                &commands,
2815                worktree,
2816                timeout,
2817                &format!("round {round}: deferred e2e, now catching up before the final decision"),
2818            )
2819            .await;
2820            let last = &mut self.state.reviews[round_idx];
2821            last.e2e = outcomes;
2822            last.verify_retried = verify_retried;
2823            last.e2e_deferred = false;
2824            if verified_head != last.head {
2825                last.verified_head = Some(verified_head);
2826            }
2827        }
2828        let last = &self.state.reviews[round_idx];
2829        let red: Vec<String> = last
2830            .e2e
2831            .iter()
2832            .filter(|o| !o.ok())
2833            .map(|o| {
2834                format!(
2835                    "`{}` -> {:?}\n{}",
2836                    o.command,
2837                    o.code,
2838                    tail(&o.output_tail, EVENT_OUTPUT_TAIL)
2839                )
2840            })
2841            .collect();
2842        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
2843
2844        if red.is_empty() {
2845            self.state.event(
2846                "review",
2847                format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
2848            );
2849            self.state.status = RunStatus::Gating;
2850        } else {
2851            self.state
2852                .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
2853            self.state.status = RunStatus::Blocked;
2854        }
2855        self.state.save()?;
2856        Ok(())
2857    }
2858
2859    // ----------------------------------------------------------------- gate
2860
2861    async fn gate(&mut self) -> Result<()> {
2862        // Judged by the review record itself, not by `status`: a solo
2863        // candidate's `judge`/`deliberate` skip rewrites `status` on every
2864        // reentry (see `judge`), and trusting it here is exactly how a run
2865        // that exhausted its review budget got gated and merged a second
2866        // time around. `review_conclusion` recomputes the review loop's own
2867        // verdict from the round records themselves — `Gating` for a clean
2868        // round or a hand-off (see `stop_reviewing`), anything else means the
2869        // loop is still going or genuinely blocked.
2870        // A base the winner could not be replayed onto is a decision, not a
2871        // round: there is no landing tree to gate. Read as its own record for
2872        // the same reason the review verdict is.
2873        if self.state.status == RunStatus::Failed
2874            || self
2875                .state
2876                .base_sync
2877                .as_ref()
2878                .is_some_and(|s| s.conflict.is_some())
2879            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
2880                != Some(RunStatus::Gating)
2881        {
2882            return Ok(());
2883        }
2884        if !self.state.gate.is_empty() {
2885            // `review_loop` derives its conclusion from the clean review
2886            // record on every reentry and therefore puts a completed run back
2887            // in `Gating`. A recorded red gate is a stronger, terminal fact:
2888            // retain its original command output and restore `Blocked` rather
2889            // than pretending the command is still running or running it a
2890            // second time. An empty list remains the only interrupted-gate
2891            // shape that may need to execute a command.
2892            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
2893                self.state.status = RunStatus::Blocked;
2894                self.state.save()?;
2895            }
2896            return Ok(());
2897        }
2898        let Some(winner) = self.state.winner().cloned() else {
2899            return Ok(());
2900        };
2901        self.state.status = RunStatus::Gating;
2902        let shell = self.state.config.shell();
2903        let outcomes = run_commands(
2904            &shell,
2905            &self.state.config.verify.gate,
2906            &winner.worktree,
2907            Duration::from_secs(self.state.config.graph.verify_timeout()),
2908        )
2909        .await;
2910        for o in &outcomes {
2911            self.state.event(
2912                "gate",
2913                format!(
2914                    "`{}` -> {}",
2915                    o.command,
2916                    if o.ok() {
2917                        "pass".to_owned()
2918                    } else {
2919                        format!(
2920                            "FAIL ({:?})\n{}",
2921                            o.code,
2922                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
2923                        )
2924                    }
2925                ),
2926            );
2927        }
2928        let passed = outcomes.iter().all(CommandOutcome::ok);
2929        self.state.gate = outcomes;
2930        if !passed {
2931            self.state.status = RunStatus::Blocked;
2932            self.state.event("gate", "gate failed; not merging");
2933        }
2934        self.state.save()?;
2935        Ok(())
2936    }
2937
2938    // ---------------------------------------------------------------- merge
2939
2940    async fn merge(&mut self) -> Result<()> {
2941        // Same reasoning as `gate`: ask the review and gate records directly
2942        // rather than `status`, which a solo-candidate `judge`/`deliberate`
2943        // skip can rewrite on reentry to something that no longer says
2944        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
2945        // so a hand-off (open findings, green verification) reaches merge
2946        // exactly like a genuinely clean round does.
2947        //
2948        // A run resumed mid-`land` never reaches here at all: `execute`
2949        // recognises `RunStatus::Landing` before it even calls `prep`, and
2950        // routes straight to `run_land` instead. That has to happen a level
2951        // up from this function, not with a check in here, because
2952        // `review_loop`'s own status recomputation (see its doc) runs
2953        // *before* `merge` on every reentry and would otherwise overwrite
2954        // the `Landing` marker with `Gating` before this node ever saw it.
2955        if self
2956            .state
2957            .base_sync
2958            .as_ref()
2959            .is_some_and(|s| s.conflict.is_some())
2960            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
2961                != Some(RunStatus::Gating)
2962            || self.state.gate.iter().any(|o| !o.ok())
2963        {
2964            return Ok(());
2965        }
2966        // This node's own record, not `status`: `status == Ready` is not
2967        // unique to the harmless `MergeMode::None` path this line was
2968        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
2969        // run's PR was closed without merging — and on that run `mode` is
2970        // still `Pr`, so a reentry that fell through here would push and
2971        // open a second pull request. `self.state.merge` is set exactly once
2972        // this node (or `land`) has already produced a verdict, under every
2973        // mode, which is what "already done" actually means here.
2974        if self.state.merge.is_some() {
2975            return Ok(());
2976        }
2977        let Some(winner) = self.state.winner().cloned() else {
2978            return Ok(());
2979        };
2980        let repo = self.state.repo.clone();
2981        let base = self.state.base_branch.clone();
2982        let mode = self.state.config.merge.mode;
2983        let style = self.state.config.merge.style;
2984        let message = pr_body(&self.state, winner.label);
2985
2986        let outcome = match mode {
2987            MergeMode::None => MergeOutcome {
2988                mode,
2989                ok: true,
2990                detail: manual_merge_command(style, &repo, &winner.branch, &message),
2991            },
2992            MergeMode::Local => {
2993                let on = git::current_branch(&repo).await?;
2994                if on.as_deref() != Some(base.as_str()) {
2995                    MergeOutcome {
2996                        mode,
2997                        ok: false,
2998                        detail: format!(
2999                            "{} has {} checked out, not the base branch {base}",
3000                            repo.display(),
3001                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
3002                        ),
3003                    }
3004                } else if !git::is_clean(&repo).await? {
3005                    MergeOutcome {
3006                        mode,
3007                        ok: false,
3008                        detail: format!("{} is dirty; refusing to merge", repo.display()),
3009                    }
3010                } else {
3011                    let out = match style {
3012                        MergeStyle::Merge => {
3013                            git::merge_no_ff(&repo, &winner.branch, &message).await?
3014                        }
3015                        MergeStyle::Squash => {
3016                            git::merge_squash(&repo, &winner.branch, &message).await?
3017                        }
3018                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
3019                    };
3020                    MergeOutcome {
3021                        mode,
3022                        ok: out.ok(),
3023                        detail: if out.ok() { out.stdout } else { out.stderr },
3024                    }
3025                }
3026            }
3027            MergeMode::Pr => {
3028                let remote = self.state.config.merge.remote.clone();
3029                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
3030                if !pushed.ok() {
3031                    MergeOutcome {
3032                        mode,
3033                        ok: false,
3034                        detail: pushed.stderr,
3035                    }
3036                } else {
3037                    let out = gh_pr_create(&winner.worktree, &base, &winner.branch, &message).await;
3038                    match out {
3039                        Ok(url) => MergeOutcome {
3040                            mode,
3041                            ok: true,
3042                            detail: url,
3043                        },
3044                        Err(e) => MergeOutcome {
3045                            mode,
3046                            ok: false,
3047                            detail: e.to_string(),
3048                        },
3049                    }
3050                }
3051            }
3052        };
3053
3054        self.state.status = match (mode, outcome.ok) {
3055            (MergeMode::None, _) => RunStatus::Ready,
3056            (_, true) => RunStatus::Merged,
3057            (_, false) => RunStatus::Blocked,
3058        };
3059        self.state.event(
3060            "merge",
3061            format!(
3062                "{:?}: {}",
3063                mode,
3064                outcome.detail.lines().next().unwrap_or("")
3065            ),
3066        );
3067        self.state.merge = Some(outcome);
3068        self.state.save()?;
3069
3070        // The PR is open and the run would historically stop here, leaving the
3071        // operator to watch checks, feed review comments back to a fixer, and
3072        // merge. That was done by hand six times in one session before this
3073        // existed. Opt-in, because merging is the one irreversible thing magi
3074        // can do to a repository.
3075        if self.state.config.graph.land
3076            && mode == MergeMode::Pr
3077            && self.state.status == RunStatus::Merged
3078        {
3079            self.run_land().await?;
3080        }
3081        // `run_land` may have left `status` at `Landing` - still waiting on
3082        // CI or the owner's approval, not actually settled - so this has to
3083        // read whatever `status` ended up as here, not the `Merged` this
3084        // function set a few lines up.
3085        self.settle_questions();
3086        Ok(())
3087    }
3088
3089    /// Enter `land`.
3090    ///
3091    /// Shared between a fresh run's first pass through [`Runner::merge`] and
3092    /// a resumed run's re-entry. `land::land` itself is what serialises the
3093    /// two git-mutating moments inside the loop — the rebase push and
3094    /// `gh pr merge` — per repository (see its own doc); nothing here needs
3095    /// to hold a lock across the whole call, and doing so would serialise
3096    /// this run's CI wait against a *different* run's land-approval resume
3097    /// in the same repository, which is exactly the "must not wait on
3098    /// another task" property the daemon's slot-freeing exists to give.
3099    async fn run_land(&mut self) -> Result<()> {
3100        let url = self
3101            .state
3102            .merge
3103            .as_ref()
3104            .map(|m| m.detail.clone())
3105            .unwrap_or_default();
3106        let url = url.lines().next().unwrap_or("").trim().to_owned();
3107        if !url.starts_with("http") {
3108            return Ok(());
3109        }
3110        // A land failure is not a lost run: the work is on a branch and the
3111        // pull request is open, which is exactly where a human takes over.
3112        match land::land(&mut self.state, &url).await {
3113            Ok(pr) if self.state.parked => {
3114                // `land` already saved the parked marker; nothing here
3115                // overrides `status` back to a terminal value while an
3116                // approval is still outstanding.
3117                let _ = pr;
3118            }
3119            Ok(pr) => {
3120                self.state.status = match pr.state {
3121                    land::PrLifecycle::Merged => RunStatus::Merged,
3122                    _ => RunStatus::Blocked,
3123                };
3124                // Downstream of a confirmed merge only - see
3125                // `bump::should_release_bump`'s own doc for why this one
3126                // check covers all three of `land`'s success paths.
3127                // Best-effort: the run already landed, so a failure here
3128                // (the decision call, `gh`, `cargo`) is recorded and never
3129                // turns a landed run into a failed one.
3130                if bump::should_release_bump(self.state.status)
3131                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
3132                {
3133                    self.state
3134                        .event("bump", format!("release bump skipped: {e:#}"));
3135                }
3136                self.state.save()?;
3137            }
3138            Err(e) => {
3139                self.state.status = RunStatus::Blocked;
3140                self.state.event("land", format!("gave up: {e}"));
3141                self.state.save()?;
3142            }
3143        }
3144        Ok(())
3145    }
3146
3147    // -------------------------------------------------------------- helpers
3148
3149    /// Fetch or create a seat, keeping its conversation across nodes.
3150    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
3151        if let Some(existing) = self.state.seats.get(key)
3152            && existing.agent == agent
3153        {
3154            return existing.clone();
3155        }
3156        let fresh = SeatState::new(key, agent, self.state.seed);
3157        self.state.seats.insert(key.to_owned(), fresh.clone());
3158        fresh
3159    }
3160
3161    /// A candidate rendered for judging, with the leak policy applied.
3162    fn view(&self, c: &Candidate) -> CandidateView {
3163        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
3164            .unwrap_or_default();
3165        let (patch, _) = blind::sanitize_patch(
3166            &format!("candidate {} patch", c.label),
3167            &raw,
3168            &self.state.config.blind,
3169        );
3170        CandidateView {
3171            label: c.label,
3172            branch: c.branch.clone(),
3173            summary: c.summary.clone(),
3174            stat: c.stat.clone(),
3175            patch,
3176        }
3177    }
3178
3179    /// The full candidate set as prompt text, for seats with no live session.
3180    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
3181        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
3182        prompt::judge(
3183            "(see above)",
3184            &views,
3185            self.roles.judges.len(),
3186            base_short,
3187            "en",
3188        )
3189    }
3190
3191    /// Anonymised transcript for judge `self_idx`.
3192    ///
3193    /// The initial rankings are always the opening statements. Seeding them
3194    /// only when no turn had been taken yet meant every judge after the first
3195    /// argued against a single voice instead of against the actual split — the
3196    /// disagreement is the information, so it is always on the table.
3197    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
3198        let mut turns = Vec::new();
3199        for j in &self.state.judgements {
3200            if j.ranking.is_empty() {
3201                continue;
3202            }
3203            let reasons = j
3204                .reasons
3205                .iter()
3206                .map(|(k, v)| format!("- {k}: {v}"))
3207                .collect::<Vec<_>>()
3208                .join("\n");
3209            turns.push(Turn {
3210                who: format!("Judge {} (opening ranking)", j.judge),
3211                is_self: j.judge == self_idx + 1,
3212                body: format!(
3213                    "Ranked {}{}{reasons}",
3214                    j.ranking.iter().collect::<String>(),
3215                    if reasons.is_empty() {
3216                        ""
3217                    } else {
3218                        ", because:\n"
3219                    }
3220                ),
3221            });
3222        }
3223        for t in self
3224            .state
3225            .deliberation
3226            .iter()
3227            .flat_map(|r| r.turns.iter())
3228            .chain(current)
3229        {
3230            turns.push(Turn {
3231                who: format!("Judge {}", t.judge),
3232                is_self: t.judge == self_idx + 1,
3233                body: t.body.clone(),
3234            });
3235        }
3236        turns
3237    }
3238}
3239
3240/// Does this seat still hold the context a follow-up prompt would rely on?
3241fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
3242    agent::has_session(spec.kind, seat, sessions)
3243}
3244
3245fn short(commit: &str) -> String {
3246    commit.chars().take(7).collect()
3247}
3248
3249fn make_executable(path: &Path) -> Result<()> {
3250    #[cfg(unix)]
3251    {
3252        use std::os::unix::fs::PermissionsExt as _;
3253        let mut perms = std::fs::metadata(path)?.permissions();
3254        perms.set_mode(0o755);
3255        std::fs::set_permissions(path, perms)?;
3256    }
3257    #[cfg(not(unix))]
3258    {
3259        let _ = path;
3260    }
3261    Ok(())
3262}
3263
3264/// What every seat in one batch shares: where the answers are attributed, the
3265/// prompt overlay they inherit, and the build cache they are told to use.
3266///
3267/// A struct rather than four more parameters: `wave` also needs the run's
3268/// state (to record who is answering right now) and the attempt number, and
3269/// eight positional arguments is both unreadable and a clippy error.
3270struct WaveCtx<'a> {
3271    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
3272    /// paid for it.
3273    run: &'a str,
3274    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
3275    node: &'a str,
3276    prompts: &'a Prompts,
3277    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
3278    cache: Option<&'a Path>,
3279}
3280
3281/// Run one job, honouring the parallelism budget.
3282async fn run_one(
3283    job: SeatJob,
3284    sem: Arc<Semaphore>,
3285    ctx: &WaveCtx<'_>,
3286    state: &mut RunState,
3287    attempt: usize,
3288) -> (SeatState, AgentOutcome) {
3289    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
3290        .await
3291        .pop()
3292        .expect("one job in, one result out");
3293    (seat, out)
3294}
3295
3296/// Run every job concurrently, capped by the semaphore, preserving order.
3297///
3298/// Every seat in the batch is recorded into [`RunState::active`] before the
3299/// wave starts and cleared as each answer lands, so the run's own record says
3300/// who is still being waited on rather than only who finished.
3301async fn wave(
3302    jobs: Vec<SeatJob>,
3303    sem: Arc<Semaphore>,
3304    ctx: &WaveCtx<'_>,
3305    state: &mut RunState,
3306    attempt: usize,
3307) -> Vec<(usize, SeatState, AgentOutcome)> {
3308    let WaveCtx {
3309        run,
3310        node,
3311        prompts,
3312        cache,
3313    } = *ctx;
3314    for job in &jobs {
3315        state.seat_started(node, &job.seat.key, job.timeout, attempt);
3316    }
3317    if let Err(e) = state.save() {
3318        // A failed persist of "who is answering right now" must not abort the
3319        // wave: the seats are already being asked, and the alternative is
3320        // losing the answers to save a status line nobody may even be
3321        // watching.
3322        tracing::warn!("could not persist in-progress seats: {e:#}");
3323    }
3324    let mut set = tokio::task::JoinSet::new();
3325    let overlay = prompts.overlay(node);
3326    for (i, mut job) in jobs.into_iter().enumerate() {
3327        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
3328        if cache.is_some() {
3329            job.prompt.push('\n');
3330            job.prompt.push_str(&prompt::build_cache_note(node));
3331        }
3332        let sem = Arc::clone(&sem);
3333        let run = run.to_owned();
3334        let node = node.to_owned();
3335        let cache = cache.map(Path::to_path_buf);
3336        set.spawn(async move {
3337            let _permit = sem.acquire().await;
3338            let mut seat = job.seat;
3339            let out = agent::invoke(
3340                &job.spec,
3341                &mut seat,
3342                &Invocation {
3343                    cwd: &job.cwd,
3344                    prompt: &job.prompt,
3345                    timeout: job.timeout,
3346                    allow_write: job.allow_write,
3347                    sessions: job.sessions,
3348                    artifacts: &job.artifacts,
3349                    stem: &job.stem,
3350                    run: &run,
3351                    node: &node,
3352                    cache_dir: cache.as_deref(),
3353                    attachments: &[],
3354                },
3355            )
3356            .await;
3357            let out = match out {
3358                Ok(o) if o.usable() => AgentOutcome::Ok(o),
3359                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
3360                // Billed work the CLI failed to hand over is not an ordinary
3361                // failure, but its text is the CLI's raw error JSON, not an
3362                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
3363                // read it as one by forgetting to check. `usable()` is always
3364                // false here (dropped implies an empty response), so this has
3365                // to be checked before the catch-all `Failed` below or the
3366                // one shape this exists for is lost with the rest.
3367                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
3368                Ok(o) if o.timed_out => AgentOutcome::Failed("timed out".to_owned()),
3369                Ok(o) => AgentOutcome::Failed(format!(
3370                    "exited with {:?} and no usable output",
3371                    o.exit_code
3372                )),
3373                Err(e) => AgentOutcome::Failed(e.to_string()),
3374            };
3375            (i, seat, out)
3376        });
3377    }
3378    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
3379    while let Some(joined) = set.join_next().await {
3380        let (i, seat, out) = match joined {
3381            Ok(v) => v,
3382            // No seat to clear: a panicked task never reported which one it
3383            // was. The defensive sweep below this loop is what stops that
3384            // seat's `active` entry from surviving forever.
3385            Err(e) => {
3386                tracing::error!("agent task panicked: {e}");
3387                continue;
3388            }
3389        };
3390        state.seat_finished(&seat.key);
3391        if let Err(e) = state.save() {
3392            tracing::warn!("could not persist a seat's completion: {e:#}");
3393        }
3394        if collected.len() <= i {
3395            collected.resize_with(i + 1, || None);
3396        }
3397        collected[i] = Some((i, seat, out));
3398    }
3399    // Belt-and-braces for the panic branch above: every seat this exact batch
3400    // started shares this `(node, attempt)` pair, and every seat that finished
3401    // normally already cleared itself, so anything left tagged with it here
3402    // can only be a panicked task's leftover. Cleared unconditionally rather
3403    // than left to read as still answering forever.
3404    if state
3405        .active
3406        .values()
3407        .any(|a| a.node == node && a.attempt == attempt)
3408    {
3409        state
3410            .active
3411            .retain(|_, a| !(a.node == node && a.attempt == attempt));
3412        if let Err(e) = state.save() {
3413            tracing::warn!("could not persist the end of a wave: {e:#}");
3414        }
3415    }
3416    collected.into_iter().flatten().collect()
3417}
3418
3419/// Is a review round clean, given how many reviewer seats answered against
3420/// how many the round expected?
3421///
3422/// A seat that never answered (timeout, crash, unparsable output) is not a
3423/// seat that read the patch and found nothing — treating it as such is
3424/// exactly the bug this function exists to close. Under the default `block`
3425/// policy a missing seat can never be clean; `warn` still requires the seats
3426/// that *did* answer to have found nothing blocking and verification to be
3427/// green.
3428///
3429/// `quota_missing` narrows that `block` default for exactly one cause of
3430/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
3431/// a session limit lifts by the very next round buys nothing — the seat is
3432/// asked again with the same quota — so once every missing seat is accounted
3433/// for by a quota loss (and at least one seat *did* answer, so a decision has
3434/// something to rest on) the round is decided on the panel that could answer,
3435/// same as `warn` would. A panel that lost every seat to quota is not
3436/// decided here: `answered == 0` falls through to the existing `block`
3437/// fallback so a fully collapsed panel still waits rather than landing on no
3438/// review at all.
3439fn round_is_clean(
3440    blocking: usize,
3441    e2e_ok: bool,
3442    answered: usize,
3443    expected: usize,
3444    quota_missing: usize,
3445    policy: IncompleteReviewPolicy,
3446) -> bool {
3447    if blocking != 0 || !e2e_ok {
3448        return false;
3449    }
3450    if answered == expected || policy == IncompleteReviewPolicy::Warn {
3451        return true;
3452    }
3453    answered > 0 && expected - answered <= quota_missing
3454}
3455
3456/// The review loop's own conclusion, derived entirely from its persisted
3457/// round records and the round budget that produced them — never from
3458/// `status`, so a reentry (or `gate`/`merge` reading it independently)
3459/// recomputes the identical answer regardless of what an earlier node in the
3460/// same walk, or a previous walk, did to `status`.
3461///
3462/// `None` while more rounds remain to try, including when review never ran
3463/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
3464/// gone clean, or the budget is spent, or the tree has stopped moving (see
3465/// [`STAGNANT_LIMIT`]), the answer is one of two things:
3466///
3467/// - An incomplete panel that raised nothing is missing input, not a
3468///   verified tree — never a hand-off candidate, whatever verification said
3469///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
3470/// - Otherwise, green e2e on the last round hands off (see
3471///   [`Runner::stop_reviewing`]); red e2e blocks.
3472fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
3473    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
3474        return Some(RunStatus::Gating);
3475    }
3476    let last = reviews.last()?;
3477    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
3478    if reviews.len() < max_rounds && !stagnant {
3479        return None;
3480    }
3481    Some(if last.incomplete() && last.blocking == 0 {
3482        RunStatus::Blocked
3483    } else if last.e2e.iter().all(CommandOutcome::ok) {
3484        RunStatus::Gating
3485    } else {
3486        RunStatus::Blocked
3487    })
3488}
3489
3490/// How long a re-ask may take, given the budget the first attempt had.
3491///
3492/// A `nudged` retry is a request to restate an answer the seat has already
3493/// worked out: it carries no new work, so it does not deserve the original
3494/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
3495/// 133 seconds while a third sat for over ten minutes on a resumed session
3496/// holding 410 KB of prior output - and because the retry had inherited the
3497/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
3498/// judging round whose other seats were long finished.
3499///
3500/// A quarter of the budget, with a floor so that a deliberately short timeout
3501/// does not collapse to nothing. A retry that re-sends the whole prompt
3502/// (because the seat kept no context) is the original job again, and keeps the
3503/// original budget.
3504fn retry_budget(full: Duration, nudged: bool) -> Duration {
3505    if nudged {
3506        (full / 4).max(Duration::from_secs(120)).min(full)
3507    } else {
3508        full
3509    }
3510}
3511
3512/// Run a wave and parse each reply, re-asking the seats whose reply was
3513/// unusable.
3514///
3515/// The re-ask is a nudge rather than the whole prompt again when the seat still
3516/// holds its conversation, which is the difference between a cheap retry and
3517/// paying for the entire candidate set twice.
3518///
3519/// A seat that hits a rate limit is **not** re-asked: the same call will fail
3520/// the same way until the limit resets, so spending a retry attempt on it is
3521/// pure waste. Its loss is recorded in `losses` and it is returned as a failure
3522/// like any other absent seat — the caller decides whether the panel still has
3523/// a quorum.
3524#[allow(clippy::too_many_arguments)]
3525async fn ask_json_wave<T>(
3526    jobs: Vec<SeatJob>,
3527    sem: Arc<Semaphore>,
3528    retries: usize,
3529    ctx: &WaveCtx<'_>,
3530    losses: &mut Vec<QuotaLoss>,
3531    state: &mut RunState,
3532    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
3533) -> Vec<(SeatState, Result<(T, AgentOutput)>)>
3534where
3535    T: serde::de::DeserializeOwned + Send + 'static,
3536{
3537    let n = jobs.len();
3538    let originals: Vec<SeatJob> = jobs;
3539    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
3540    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
3541    let mut pending: Vec<usize> = (0..n).collect();
3542
3543    for attempt in 0..=retries {
3544        if pending.is_empty() {
3545            break;
3546        }
3547        let mut batch = Vec::with_capacity(pending.len());
3548        for &i in &pending {
3549            let src = &originals[i];
3550            // The prompt and the budget are one decision: a nudge restates
3551            // finished work, a re-sent prompt redoes it.
3552            let (prompt, timeout) = if attempt == 0 {
3553                (src.prompt.clone(), src.timeout)
3554            } else {
3555                let why = done[i]
3556                    .as_ref()
3557                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
3558                    .unwrap_or_else(|| "no parsable answer".to_owned());
3559                let nudge = prompt::nudge(&why);
3560                let nudged = has_context(&src.spec, &seats[i], src.sessions);
3561                let prompt = if nudged {
3562                    nudge
3563                } else {
3564                    format!("{}\n\n---\n\n{}", src.prompt, nudge)
3565                };
3566                (prompt, retry_budget(src.timeout, nudged))
3567            };
3568            batch.push(SeatJob {
3569                spec: src.spec.clone(),
3570                seat: seats[i].clone(),
3571                cwd: src.cwd.clone(),
3572                prompt,
3573                timeout,
3574                allow_write: src.allow_write,
3575                sessions: src.sessions,
3576                artifacts: src.artifacts.clone(),
3577                stem: if attempt == 0 {
3578                    src.stem.clone()
3579                } else {
3580                    format!("{}-retry{attempt}", src.stem)
3581                },
3582            });
3583        }
3584
3585        if attempt > 0 {
3586            let seats_out: Vec<&str> = pending
3587                .iter()
3588                .map(|&i| originals[i].seat.key.as_str())
3589                .collect();
3590            state.event(
3591                ctx.node,
3592                format!("retry {attempt}: re-asking {}", seats_out.join(", ")),
3593            );
3594        }
3595        let results = wave(batch, Arc::clone(&sem), ctx, state, attempt).await;
3596        let mut still = Vec::new();
3597        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
3598            seats[i] = seat;
3599            let (parsed, quota) = match out {
3600                AgentOutcome::Ok(o) => (
3601                    match verdict::extract_json::<T>(&o.text) {
3602                        Ok(v) => match validate(&v) {
3603                            Ok(()) => Ok((v, o)),
3604                            Err(e) => Err(e),
3605                        },
3606                        Err(e) => Err(e),
3607                    },
3608                    false,
3609                ),
3610                AgentOutcome::Quota(o) => {
3611                    losses.push(QuotaLoss {
3612                        seat: originals[i].seat.key.clone(),
3613                        node: ctx.node.to_owned(),
3614                        at: Timestamp::now(),
3615                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3616                    });
3617                    (
3618                        Err(anyhow::anyhow!("rate limited (quota); not retrying now")),
3619                        true,
3620                    )
3621                }
3622                // Not a parseable answer, but also not worth a special-cased
3623                // retry here: the nudge loop above already re-asks anything
3624                // that fails to parse, which is exactly what a dropped stream
3625                // needs. Just don't hand its raw error JSON to `extract_json`.
3626                AgentOutcome::Dropped(o) => {
3627                    let why = o
3628                        .dropped
3629                        .as_ref()
3630                        .map(|d| d.why.as_str())
3631                        .unwrap_or("the CLI ended the stream without delivering its answer");
3632                    (
3633                        Err(anyhow::anyhow!("the CLI dropped the stream ({why})")),
3634                        false,
3635                    )
3636                }
3637                AgentOutcome::Failed(e) => (Err(anyhow::anyhow!(e)), false),
3638            };
3639            let failed = parsed.is_err();
3640            done[i] = Some(parsed);
3641            // Do not re-ask a rate-limited seat (quota) — a retry is known to
3642            // fail the same way; and never re-ask a seat that already parsed.
3643            if failed && !quota {
3644                still.push(i);
3645            }
3646        }
3647        pending = still;
3648    }
3649
3650    seats
3651        .into_iter()
3652        .zip(done)
3653        .map(|(seat, res)| {
3654            (
3655                seat,
3656                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
3657            )
3658        })
3659        .collect()
3660}
3661
3662/// Describe one verify command's outcome for the event log, distinguishing a
3663/// build/link failure — the toolchain never produced a binary to run — from
3664/// an actual test failure, since only the latter is a verdict on the patch.
3665fn e2e_outcome_label(o: &CommandOutcome) -> String {
3666    if o.ok() {
3667        return "pass".to_owned();
3668    }
3669    let reason = if o.build_failed() {
3670        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
3671    } else {
3672        format!("FAIL ({:?})", o.code)
3673    };
3674    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
3675}
3676
3677/// Run `verify.e2e`, retrying once if the first attempt could not build or
3678/// link — a build/link failure is frequently a race against a shared
3679/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
3680/// `verify` event per command, tagged with `context` (normally `"round N"`)
3681/// so the two call sites that need this — the ordinary per-round leg in
3682/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
3683/// it will ever call a round green — read identically in the event log.
3684async fn run_e2e_with_retry(
3685    state: &mut RunState,
3686    shell: &[String],
3687    commands: &[String],
3688    worktree: &Path,
3689    timeout: Duration,
3690    context: &str,
3691) -> (Vec<CommandOutcome>, bool) {
3692    let mut e2e = run_commands(shell, commands, worktree, timeout).await;
3693    for o in &e2e {
3694        state.event(
3695            "verify",
3696            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
3697        );
3698    }
3699    // A build/link failure is not a verdict on the patch — it is frequently a
3700    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
3701    // one retry before letting a red like that decide the round.
3702    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
3703    if verify_retried {
3704        state.event(
3705            "verify",
3706            format!(
3707                "{context}: verify could not build/link, not a test result — retrying once \
3708                 before concluding"
3709            ),
3710        );
3711        e2e = run_commands(shell, commands, worktree, timeout).await;
3712        for o in &e2e {
3713            state.event(
3714                "verify",
3715                format!(
3716                    "{context}: retry `{}` -> {}",
3717                    o.command,
3718                    e2e_outcome_label(o)
3719                ),
3720            );
3721        }
3722    }
3723    (e2e, verify_retried)
3724}
3725
3726/// Run configured shell commands in `cwd`, in order.
3727async fn run_commands(
3728    shell: &[String],
3729    commands: &[String],
3730    cwd: &Path,
3731    timeout: Duration,
3732) -> Vec<CommandOutcome> {
3733    let mut out = Vec::new();
3734    for command in commands {
3735        let started = Instant::now();
3736        let mut cmd = tokio::process::Command::new(&shell[0]);
3737        cmd.quiet();
3738        cmd.args(&shell[1..])
3739            .arg(command)
3740            .current_dir(cwd)
3741            .stdin(std::process::Stdio::null())
3742            .stdout(std::process::Stdio::piped())
3743            .stderr(std::process::Stdio::piped())
3744            .kill_on_drop(true);
3745        let spawned = cmd.spawn();
3746        let (code, body) = match spawned {
3747            Ok(child) => match tokio::time::timeout(timeout, child.wait_with_output()).await {
3748                Ok(Ok(o)) => {
3749                    let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
3750                    body.push_str(&String::from_utf8_lossy(&o.stderr));
3751                    (o.status.code(), body)
3752                }
3753                Ok(Err(e)) => (None, format!("failed to run: {e}")),
3754                Err(_) => (None, format!("timed out after {}s", timeout.as_secs())),
3755            },
3756            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
3757        };
3758        out.push(CommandOutcome {
3759            command: command.clone(),
3760            code,
3761            output_tail: tail(&body, OUTPUT_TAIL),
3762            duration_ms: started.elapsed().as_millis() as u64,
3763        });
3764    }
3765    out
3766}
3767
3768/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
3769/// section (`report::run`) and in the `merge` event this node records — for
3770/// the operator to run by hand.
3771///
3772/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
3773/// branch whose ruleset forbids merge commits (GitHub's "must not contain
3774/// merge commits", or "require linear history") rejects the push a `--no-ff`
3775/// merge would produce, which is exactly the guidance this function replaces.
3776/// `message`'s first line becomes the squash commit's subject, matching the
3777/// note `report::run` prints alongside this command — see that function for
3778/// why an explicit subject is not optional there.
3779fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
3780    let repo = repo.display();
3781    match style {
3782        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
3783        MergeStyle::Squash => {
3784            let subject = message.lines().next().unwrap_or(branch);
3785            format!(
3786                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
3787            )
3788        }
3789        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
3790    }
3791}
3792
3793/// The merge commit / pull request body: the task, and — when the winning
3794/// review round was not clean — the findings still open and whatever the
3795/// fixer declined, so `merge = "pr"` hands the reader the same material
3796/// `magi show` does rather than a pull request that reads clean while
3797/// `run.json` disagrees.
3798///
3799/// The first line doubles as the pull request title (`gh_pr_create`) and the
3800/// squash/merge commit subject (`manual_merge_command`), both of which take
3801/// it via `message.lines().next()` rather than as a separate argument — so it
3802/// has to be the task's own opening line, not run/candidate bookkeeping.
3803/// "Merge magi run ec12 (candidate B)" told a reader nothing about what
3804/// landed once the run id had scrolled off the PR list. That bookkeeping
3805/// still needs to be findable, just not from the title: the branch name
3806/// already carries it (`RunState::branch_for`), and the footer below repeats
3807/// it as plain tags for a reader holding only the merged commit or the PR
3808/// body.
3809///
3810/// `state.instruction` can open with blank lines — a `--file` task is passed
3811/// through verbatim (`task_text` only rejects a body that is blank
3812/// *entirely*) — and `.lines().next()` on those reads back as `Some("")`, not
3813/// `None`, so `gh_pr_create`'s `unwrap_or("magi run")` never fires and `gh pr
3814/// create` would be asked for an empty `--title`. `trim_start` drops exactly
3815/// those leading blank lines so the first line is the task's real opening
3816/// line, and the empty-after-trim case (a whitespace-only instruction) falls
3817/// back the same way `queue::title_from` does for the same situation.
3818fn pr_body(state: &RunState, winner: char) -> String {
3819    let instruction = state.instruction.trim_start();
3820    let mut message = if instruction.is_empty() {
3821        "(empty task)".to_owned()
3822    } else {
3823        instruction.to_owned()
3824    };
3825
3826    let open = state.open_findings();
3827    if !open.is_empty() {
3828        message.push_str("\n\n## Open review findings\n\n");
3829        for f in &open {
3830            message.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
3831        }
3832    }
3833
3834    if let Some(fix) = state.reviews.last().and_then(|r| r.fix.as_ref())
3835        && !fix.rejected.is_empty()
3836    {
3837        message.push_str("\n## Declined by the fixer\n\n");
3838        for r in &fix.rejected {
3839            message.push_str(&format!("- `{}`: {}\n", r.id, r.why));
3840        }
3841    }
3842
3843    message.push_str(&format!(
3844        "\n\n---\nmagi:run/{} magi:candidate-{}\n",
3845        state.id,
3846        winner.to_ascii_lowercase()
3847    ));
3848
3849    message
3850}
3851
3852/// `gh pr create`, returning the PR url.
3853async fn gh_pr_create(cwd: &Path, base: &str, head: &str, body: &str) -> Result<String> {
3854    let title = body.lines().next().unwrap_or("magi run").to_owned();
3855    let out = tokio::process::Command::new("gh")
3856        .args([
3857            "pr", "create", "--base", base, "--head", head, "--title", &title, "--body", body,
3858        ])
3859        .current_dir(cwd)
3860        .quiet()
3861        .stdin(std::process::Stdio::null())
3862        .output()
3863        .await
3864        .context("spawn gh")?;
3865    if out.status.success() {
3866        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
3867    } else {
3868        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
3869    }
3870}
3871
3872/// Tear a run's worktrees and branches down.
3873pub async fn fold_run(state: &mut RunState, drop_winner: bool) -> Result<Vec<String>> {
3874    let repo = state.repo.clone();
3875    let root = state.worktree_root();
3876    let winner = state.tally.as_ref().map(|t| t.winner);
3877    let mut removed = Vec::new();
3878
3879    for i in 0..state.candidates.len() {
3880        let c = state.candidates[i].clone();
3881        let is_winner = Some(c.label) == winner;
3882        if is_winner && !drop_winner {
3883            continue;
3884        }
3885        if c.worktree.exists() {
3886            git::worktree_remove(&repo, &c.worktree).await.ok();
3887            removed.push(c.worktree.to_string_lossy().into_owned());
3888        }
3889        if git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
3890            git::branch_delete(&repo, &c.branch).await.ok();
3891            removed.push(c.branch.clone());
3892        }
3893        state.candidates[i].folded = true;
3894    }
3895
3896    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
3897        let path = name.path();
3898        let keep = !drop_winner
3899            && winner.is_some_and(|w| {
3900                path.file_name()
3901                    .is_some_and(|n| n == format!("cand-{w}").as_str())
3902            });
3903        if keep {
3904            continue;
3905        }
3906        git::worktree_remove(&repo, &path).await.ok();
3907        removed.push(path.to_string_lossy().into_owned());
3908    }
3909
3910    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
3911    // judge worktrees, so once the loop above has cleared all of them out,
3912    // the parent is a bare directory nobody else was ever going to remove -
3913    // git only ever managed what was inside it. Left alone, one of these
3914    // accumulates per fully-folded run; the operator's own machine had 74.
3915    // `remove_if_empty` re-checks rather than assuming: a run whose winner
3916    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
3917    // so does anything a run never claimed that happens to share the bay.
3918    remove_if_empty(&root);
3919
3920    if state.enabled_worktree_config && drop_winner {
3921        // A release, not a raw disable: some sibling run in this repository
3922        // may still hold its own reference (see `git::acquire_worktree_config`),
3923        // and only the last release actually turns the setting back off.
3924        git::release_worktree_config(&repo).await.ok();
3925        state.enabled_worktree_config = false;
3926    }
3927    state.save()?;
3928    Ok(removed)
3929}
3930
3931/// Remove `dir` if it exists and has nothing in it.
3932///
3933/// Best-effort and silent by design: a directory that is not empty (a run
3934/// whose winner is still parked there, a stray file some other process left)
3935/// is exactly the case this must refuse, and a directory that is already gone
3936/// is not a failure worth reporting either. `std::fs::remove_dir` itself
3937/// already refuses a non-empty directory, so the emptiness check below is
3938/// belt, not suspenders - it is what keeps this from ever attempting the
3939/// removal in the case that matters, rather than trusting `remove_dir`'s
3940/// error path to have no side effects if it ever changed.
3941fn remove_if_empty(dir: &Path) {
3942    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
3943        std::fs::remove_dir(dir).ok();
3944    }
3945}
3946
3947/// Severity of the worst open finding in the last review round, for reporting.
3948pub fn worst_open(state: &RunState) -> Option<Severity> {
3949    state
3950        .reviews
3951        .last()?
3952        .reviews
3953        .iter()
3954        .flat_map(|r| r.findings.iter())
3955        .map(|f| f.severity)
3956        .max()
3957}
3958
3959#[cfg(test)]
3960mod tests {
3961    use super::*;
3962    use std::collections::BTreeMap;
3963    use std::time::Duration;
3964
3965    fn conductor() -> AgentSpec {
3966        AgentSpec {
3967            id: "conductor".to_owned(),
3968            kind: crate::config::AgentKind::Command,
3969            model: None,
3970            command: vec!["true".to_owned()],
3971            extra_args: Vec::new(),
3972            env: BTreeMap::new(),
3973            prompt_delivery: None,
3974        }
3975    }
3976
3977    #[test]
3978    fn remove_if_empty_only_ever_takes_a_bare_directory() {
3979        let dir = tempfile::tempdir().unwrap();
3980        let bay = dir.path().join("ffff");
3981
3982        // Not there yet: nothing to do, nothing to panic on.
3983        remove_if_empty(&bay);
3984        assert!(!bay.exists());
3985
3986        // Something still inside - the winner's worktree, or a stray file -
3987        // keeps the directory standing.
3988        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
3989        remove_if_empty(&bay);
3990        assert!(bay.exists(), "non-empty directory must survive");
3991
3992        // Once the last entry is gone, so is the directory itself.
3993        std::fs::remove_dir(bay.join("cand-A")).unwrap();
3994        remove_if_empty(&bay);
3995        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
3996    }
3997
3998    // `round_is_clean` is the exact decision this task fixed: a round with a
3999    // seat that never answered must not read the same as a round every seat
4000    // actually reviewed. These are deterministic and process-free by design —
4001    // the equivalent end-to-end check (a real reviewer timing out under a
4002    // live graph run) is a genuine race against wall-clock contention, and a
4003    // spawn slow enough to blow even a generous budget under a loaded test
4004    // run must not turn this specific regression check flaky.
4005
4006    #[test]
4007    fn a_full_panel_that_found_nothing_is_clean() {
4008        assert!(round_is_clean(
4009            0,
4010            true,
4011            2,
4012            2,
4013            0,
4014            IncompleteReviewPolicy::Block
4015        ));
4016    }
4017
4018    #[test]
4019    fn a_missing_seat_is_never_clean_under_the_default_policy() {
4020        assert!(!round_is_clean(
4021            0,
4022            true,
4023            1,
4024            2,
4025            0,
4026            IncompleteReviewPolicy::Block
4027        ));
4028    }
4029
4030    #[test]
4031    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
4032        assert!(!round_is_clean(
4033            1,
4034            true,
4035            1,
4036            2,
4037            0,
4038            IncompleteReviewPolicy::Warn
4039        ));
4040    }
4041
4042    #[test]
4043    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
4044        assert!(round_is_clean(
4045            0,
4046            true,
4047            1,
4048            2,
4049            0,
4050            IncompleteReviewPolicy::Warn
4051        ));
4052    }
4053
4054    #[test]
4055    fn a_full_panel_with_an_open_finding_is_not_clean() {
4056        assert!(!round_is_clean(
4057            1,
4058            true,
4059            2,
4060            2,
4061            0,
4062            IncompleteReviewPolicy::Block
4063        ));
4064    }
4065
4066    #[test]
4067    fn a_full_panel_with_a_red_e2e_is_not_clean() {
4068        assert!(!round_is_clean(
4069            0,
4070            false,
4071            2,
4072            2,
4073            0,
4074            IncompleteReviewPolicy::Block
4075        ));
4076    }
4077
4078    // The stall this task closes: under the default `block` policy, a seat
4079    // missing only because it was rate limited must not force a wait for a
4080    // session limit that will not lift by the next round. `round_is_clean`
4081    // is where that quorum carve-out lives; the review loop around it never
4082    // changes what a reviewer's vote or a finding's severity means.
4083
4084    #[test]
4085    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
4086        // 1 of 2 answered, and the one missing was quota'd — the exact
4087        // "review-2 rate limited (quota)" shape from the field report.
4088        assert!(round_is_clean(
4089            0,
4090            true,
4091            1,
4092            2,
4093            1,
4094            IncompleteReviewPolicy::Block
4095        ));
4096    }
4097
4098    #[test]
4099    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
4100        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
4101        // not a quota loss (`quota_missing` stays 0) — worth another try.
4102        assert!(!round_is_clean(
4103            0,
4104            true,
4105            1,
4106            2,
4107            0,
4108            IncompleteReviewPolicy::Block
4109        ));
4110    }
4111
4112    #[test]
4113    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
4114        assert!(!round_is_clean(
4115            1,
4116            true,
4117            1,
4118            2,
4119            1,
4120            IncompleteReviewPolicy::Block
4121        ));
4122        assert!(!round_is_clean(
4123            0,
4124            false,
4125            1,
4126            2,
4127            1,
4128            IncompleteReviewPolicy::Block
4129        ));
4130    }
4131
4132    #[test]
4133    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
4134        // Every seat quota'd, nobody answered: there is no panel to decide
4135        // on, so this must fall through to the existing block-and-retry
4136        // fallback rather than call an unreviewed patch clean.
4137        assert!(!round_is_clean(
4138            0,
4139            true,
4140            0,
4141            2,
4142            2,
4143            IncompleteReviewPolicy::Block
4144        ));
4145    }
4146
4147    // `review_conclusion` is the exact decision the review hand-off task
4148    // fixed: a round budget spent (or a tree that stopped moving) must not
4149    // collapse into `Blocked` regardless of what verification actually
4150    // said. Deterministic and process-free for the same reason the
4151    // `round_is_clean` family above is.
4152    fn review_round(
4153        clean: bool,
4154        blocking: usize,
4155        answered: usize,
4156        expected: usize,
4157        progressed: bool,
4158        e2e_ok: bool,
4159    ) -> ReviewRound {
4160        ReviewRound {
4161            round: 1,
4162            head: "h".to_owned(),
4163            verified_head: None,
4164            reviews: Vec::new(),
4165            e2e: vec![CommandOutcome {
4166                command: "test".to_owned(),
4167                code: Some(if e2e_ok { 0 } else { 1 }),
4168                output_tail: String::new(),
4169                duration_ms: 0,
4170            }],
4171            verify_retried: false,
4172            e2e_deferred: false,
4173            e2e_defer_reason: None,
4174            fix: None,
4175            blocking,
4176            answered,
4177            expected,
4178            clean,
4179            progressed,
4180            vote_split: false,
4181            reconsideration: Vec::new(),
4182            verdict: None,
4183        }
4184    }
4185
4186    #[test]
4187    fn review_conclusion_is_none_when_nothing_has_run() {
4188        assert_eq!(review_conclusion(&[], 3), None);
4189    }
4190
4191    #[test]
4192    fn review_conclusion_is_none_while_rounds_remain() {
4193        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
4194        assert_eq!(review_conclusion(&rounds, 3), None);
4195    }
4196
4197    #[test]
4198    fn review_conclusion_is_gating_once_a_round_is_clean() {
4199        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
4200        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
4201    }
4202
4203    #[test]
4204    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
4205        let rounds = vec![
4206            review_round(false, 1, 2, 2, true, true),
4207            review_round(false, 1, 2, 2, true, true),
4208        ];
4209        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
4210    }
4211
4212    #[test]
4213    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
4214        let rounds = vec![
4215            review_round(false, 1, 2, 2, true, true),
4216            review_round(false, 1, 2, 2, true, false),
4217        ];
4218        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
4219    }
4220
4221    #[test]
4222    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
4223        // Missing input, not a verified tree — never a hand-off candidate.
4224        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
4225        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
4226    }
4227
4228    #[test]
4229    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
4230        let rounds = vec![
4231            review_round(false, 1, 2, 2, false, true),
4232            review_round(false, 1, 2, 2, false, true),
4233        ];
4234        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
4235    }
4236
4237    fn secs(n: u64) -> Duration {
4238        Duration::from_secs(n)
4239    }
4240
4241    /// A throwaway repo with one commit on `main`, for tests that need `merge`
4242    /// to make real (and, if it runs at all, real*ly fail*) git calls.
4243    fn init_repo(dir: &Path) {
4244        let run = |args: &[&str]| {
4245            let out = std::process::Command::new("git")
4246                .args(args)
4247                .current_dir(dir)
4248                .quiet()
4249                .output()
4250                .expect("spawn git");
4251            assert!(
4252                out.status.success(),
4253                "git {args:?} failed: {}",
4254                String::from_utf8_lossy(&out.stderr)
4255            );
4256        };
4257        run(&["init", "-b", "main"]);
4258        run(&["config", "user.name", "magi test"]);
4259        run(&["config", "user.email", "magi@example.com"]);
4260        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
4261        run(&["add", "-A"]);
4262        run(&["commit", "-m", "init"]);
4263    }
4264
4265    // `settle_questions` is what closes the ghost the phone showed: a run's
4266    // seat asked something, the run then ended, and nothing was left to
4267    // abandon the question it left `open`. `HOME` is a process-wide
4268    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
4269    // first time it runs in the binary — every test below still reaches the
4270    // same directory whichever call won, and each gets its own run id from
4271    // `RunState::new`, so they never collide there.
4272    fn ask_test_home() {
4273        crate::run::set_home(std::env::temp_dir().join("magi-graph-ask-tests-home"));
4274    }
4275
4276    /// A minimal, git-free `Runner` at a given status — `settle_questions`
4277    /// reads nothing else off it.
4278    fn runner_at(status: RunStatus) -> Runner {
4279        let mut state = RunState::new(
4280            PathBuf::from("/nonexistent/repo"),
4281            "main".to_owned(),
4282            "deadbeef".to_owned(),
4283            "task".to_owned(),
4284            Config::default(),
4285        );
4286        state.status = status;
4287        Runner {
4288            state,
4289            roles: ResolvedRoles {
4290                implementers: Vec::new(),
4291                judges: Vec::new(),
4292                reviewers: Vec::new(),
4293                fixer: None,
4294                conductor: conductor(),
4295            },
4296            sem: Arc::new(Semaphore::new(1)),
4297            pause: Pause::new(),
4298        }
4299    }
4300
4301    /// A fresh open question on `run`, stored and handed back for assertions.
4302    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
4303        let mut q = ask::Question::new(
4304            run.to_owned(),
4305            "implement".to_owned(),
4306            "impl-A".to_owned(),
4307            "Which storage backend should the cache use?".to_owned(),
4308            String::new(),
4309            vec!["SQLite".to_owned(), "Redis".to_owned()],
4310        );
4311        store.put(&mut q).unwrap();
4312        q
4313    }
4314
4315    #[test]
4316    fn a_failed_runs_open_question_is_abandoned() {
4317        ask_test_home();
4318        let store = ask::Questions::open();
4319        let mut runner = runner_at(RunStatus::Failed);
4320        let run = runner.state.id.clone();
4321        let q = ask_open_question(&store, &run);
4322
4323        runner.settle_questions();
4324
4325        let back = store.get(&q.id).unwrap();
4326        assert!(
4327            !back.status.open(),
4328            "the seat that asked died with the run; nobody is left to read an answer"
4329        );
4330        assert!(
4331            back.detail.contains(&run) && back.detail.contains("failed"),
4332            "the reason names what the run became, not just that it is gone: {}",
4333            back.detail
4334        );
4335    }
4336
4337    #[test]
4338    fn a_merged_runs_open_question_is_abandoned_too() {
4339        ask_test_home();
4340        let store = ask::Questions::open();
4341        // A run that finishes cleanly still leaves nobody to read an answer -
4342        // this is not only a failure-path cleanup.
4343        for status in [RunStatus::Merged, RunStatus::Ready] {
4344            let mut runner = runner_at(status);
4345            let run = runner.state.id.clone();
4346            let q = ask_open_question(&store, &run);
4347
4348            runner.settle_questions();
4349
4350            let back = store.get(&q.id).unwrap();
4351            assert!(
4352                !back.status.open(),
4353                "{status:?} run's question must not outlive the run"
4354            );
4355        }
4356    }
4357
4358    #[test]
4359    fn a_still_resumable_runs_open_question_is_left_alone() {
4360        ask_test_home();
4361        let store = ask::Questions::open();
4362        // `Blocked` and `Stalled` can still be resumed — the candidates, the
4363        // review round and the seat sessions are all still on disk — so a
4364        // question asked mid-round may yet get a real answer from a real
4365        // resume. Sweeping it here would be exactly the failure mode this
4366        // whole feature exists to avoid on the other side.
4367        for status in [RunStatus::Blocked, RunStatus::Stalled] {
4368            let mut runner = runner_at(status);
4369            let run = runner.state.id.clone();
4370            let q = ask_open_question(&store, &run);
4371
4372            runner.settle_questions();
4373
4374            let back = store.get(&q.id).unwrap();
4375            assert!(
4376                back.status.open(),
4377                "{status:?} is still alive; the question must still be waiting"
4378            );
4379        }
4380    }
4381
4382    #[test]
4383    fn settle_questions_never_touches_an_already_answered_question() {
4384        ask_test_home();
4385        let store = ask::Questions::open();
4386        let mut runner = runner_at(RunStatus::Failed);
4387        let run = runner.state.id.clone();
4388        let mut q = ask_open_question(&store, &run);
4389        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
4390            .unwrap();
4391        store.put(&mut q).unwrap();
4392
4393        // Called twice, the way a crash-recovered daemon reclaim and the
4394        // graph's own cleanup both can for the same run — `abandon_for_run`
4395        // only ever touches what is still open, so this must be inert both
4396        // times, not merely the second.
4397        runner.settle_questions();
4398        runner.settle_questions();
4399
4400        let back = store.get(&q.id).unwrap();
4401        assert_eq!(
4402            back.status,
4403            ask::QuestionStatus::Answered,
4404            "a real answer is a decision on record, never overwritten by a sweep"
4405        );
4406    }
4407
4408    /// `status == Ready` used to be read as "this is the harmless
4409    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
4410    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
4411    /// run's PR was closed without merging — and reentering `merge` with
4412    /// `mode` still `Pr` does not know the difference, so it pushed and
4413    /// opened a second pull request. `mode == Local` reproduces the same
4414    /// blind spot without a network call: reentry must not attempt another
4415    /// git merge once this node has already recorded an outcome.
4416    #[tokio::test]
4417    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
4418        let tmp = tempfile::tempdir().expect("tempdir");
4419        let repo = tmp.path().join("repo");
4420        std::fs::create_dir_all(&repo).unwrap();
4421        init_repo(&repo);
4422
4423        let mut config = Config::default();
4424        config.merge.mode = MergeMode::Local;
4425
4426        let mut state = RunState::new(
4427            repo.clone(),
4428            "main".to_owned(),
4429            "deadbeef".to_owned(),
4430            "task".to_owned(),
4431            config,
4432        );
4433        state.candidates = vec![Candidate {
4434            index: 0,
4435            label: 'A',
4436            agent: "alpha".to_owned(),
4437            branch: "does-not-exist".to_owned(),
4438            worktree: repo.clone(),
4439            summary: String::new(),
4440            stat: String::new(),
4441            files: 0,
4442            commits: 0,
4443            empty: false,
4444            failed: None,
4445            duration_ms: 0,
4446            folded: false,
4447        }];
4448        state.tally = Some(Tally {
4449            first_choice: BTreeMap::from([('A', 1)]),
4450            borda: BTreeMap::new(),
4451            winner: 'A',
4452            rankings: 1,
4453            unanimous_initial: true,
4454            deliberated: false,
4455            changed_votes: 0,
4456            unanimous_final: true,
4457            tie_break: None,
4458            judges: 0,
4459            present: 0,
4460            quorum: 0,
4461            met_quorum: true,
4462            uncontested: Some("only candidate A produced a change".to_owned()),
4463        });
4464        state.reviews = vec![ReviewRound {
4465            round: 1,
4466            head: "deadbeef".to_owned(),
4467            verified_head: None,
4468            reviews: Vec::new(),
4469            e2e: Vec::new(),
4470            fix: None,
4471            blocking: 0,
4472            answered: 0,
4473            expected: 0,
4474            clean: true,
4475            verify_retried: false,
4476            e2e_deferred: false,
4477            e2e_defer_reason: None,
4478            progressed: false,
4479            vote_split: false,
4480            reconsideration: Vec::new(),
4481            verdict: None,
4482        }];
4483        state.gate = vec![CommandOutcome {
4484            command: "test".to_owned(),
4485            code: Some(0),
4486            output_tail: String::new(),
4487            duration_ms: 0,
4488        }];
4489        // Reached its conclusion already — e.g. `land` closing the PR without
4490        // merging it, which (like the honest `MergeMode::None` path) leaves
4491        // `status` at `Ready`. The recorded outcome is what actually marks
4492        // this node done.
4493        state.status = RunStatus::Ready;
4494        state.merge = Some(MergeOutcome {
4495            mode: MergeMode::Local,
4496            ok: false,
4497            detail: "already concluded".to_owned(),
4498        });
4499
4500        let mut runner = Runner {
4501            state,
4502            roles: ResolvedRoles {
4503                implementers: Vec::new(),
4504                judges: Vec::new(),
4505                reviewers: Vec::new(),
4506                fixer: None,
4507                conductor: conductor(),
4508            },
4509            sem: Arc::new(Semaphore::new(1)),
4510            pause: Pause::new(),
4511        };
4512
4513        runner.merge().await.expect("merge");
4514
4515        assert_eq!(
4516            runner.state.status,
4517            RunStatus::Ready,
4518            "a concluded run's status must not change on reentry"
4519        );
4520        assert_eq!(
4521            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
4522            Some("already concluded"),
4523            "merge must not run again once the node already recorded an outcome"
4524        );
4525    }
4526
4527    #[tokio::test]
4528    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
4529        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
4530        let tmp = tempfile::tempdir().expect("tempdir");
4531        let repo = tmp.path().join("repo");
4532        std::fs::create_dir_all(&repo).unwrap();
4533        init_repo(&repo);
4534
4535        let mut config = Config::default();
4536        config.merge.mode = MergeMode::Pr;
4537        config.graph.land = true;
4538        config.graph.land_approval = false;
4539
4540        let mut state = RunState::new(
4541            repo.clone(),
4542            "main".to_owned(),
4543            "deadbeef".to_owned(),
4544            "task".to_owned(),
4545            config,
4546        );
4547        state.candidates = vec![Candidate {
4548            index: 0,
4549            label: 'A',
4550            agent: "alpha".to_owned(),
4551            branch: "does-not-exist".to_owned(),
4552            worktree: repo.clone(),
4553            summary: String::new(),
4554            stat: String::new(),
4555            files: 0,
4556            commits: 0,
4557            empty: false,
4558            failed: None,
4559            duration_ms: 0,
4560            folded: false,
4561        }];
4562        state.tally = Some(Tally {
4563            first_choice: BTreeMap::from([('A', 1)]),
4564            borda: BTreeMap::new(),
4565            winner: 'A',
4566            rankings: 1,
4567            unanimous_initial: true,
4568            deliberated: false,
4569            changed_votes: 0,
4570            unanimous_final: true,
4571            tie_break: None,
4572            judges: 0,
4573            present: 0,
4574            quorum: 0,
4575            met_quorum: true,
4576            uncontested: Some("only candidate A produced a change".to_owned()),
4577        });
4578        state.reviews = vec![ReviewRound {
4579            round: 1,
4580            head: "deadbeef".to_owned(),
4581            verified_head: None,
4582            reviews: Vec::new(),
4583            e2e: Vec::new(),
4584            fix: None,
4585            blocking: 0,
4586            answered: 0,
4587            expected: 0,
4588            clean: true,
4589            verify_retried: false,
4590            e2e_deferred: false,
4591            e2e_defer_reason: None,
4592            progressed: false,
4593            vote_split: false,
4594            reconsideration: Vec::new(),
4595            verdict: None,
4596        }];
4597        state.gate = vec![CommandOutcome {
4598            command: "test".to_owned(),
4599            code: Some(0),
4600            output_tail: String::new(),
4601            duration_ms: 0,
4602        }];
4603        // A first pass through `merge` already pushed and opened this pull
4604        // request; `status` is `Landing` because a previous call into `land`
4605        // parked or was interrupted before it reached a terminal outcome.
4606        state.status = RunStatus::Landing;
4607        state.merge = Some(MergeOutcome {
4608            mode: MergeMode::Pr,
4609            ok: true,
4610            detail: "https://example.invalid/x/y/pull/1".to_owned(),
4611        });
4612
4613        // The Landing-resume shortcut calls `run_land` directly rather than
4614        // through `merge`, which is exactly the call site that used to skip
4615        // `settle_questions` - see the fixture below.
4616        ask_test_home();
4617        let store = ask::Questions::open();
4618        let q = ask_open_question(&store, &state.id);
4619
4620        let mut runner = Runner {
4621            state,
4622            roles: ResolvedRoles {
4623                implementers: Vec::new(),
4624                judges: Vec::new(),
4625                reviewers: Vec::new(),
4626                fixer: None,
4627                conductor: conductor(),
4628            },
4629            sem: Arc::new(Semaphore::new(1)),
4630            pause: Pause::new(),
4631        };
4632
4633        // `execute`, not `merge` directly: the Landing-resume shortcut lives
4634        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
4635        // exactly because `review_loop` would otherwise clobber the marker
4636        // first.
4637        runner.execute().await.expect("execute");
4638
4639        assert_eq!(
4640            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
4641            Some("https://example.invalid/x/y/pull/1"),
4642            "reentry must not push again or open a second pull request over the \
4643             one `land` is already watching"
4644        );
4645        assert_ne!(
4646            runner.state.status,
4647            RunStatus::Landing,
4648            "land could not actually reach the fake pull request, so it must \
4649             have given up rather than left the run silently parked forever"
4650        );
4651        // `land` could not reach the fake pull request, so it gave up into
4652        // `Blocked` - still resumable, so the question must not have been
4653        // swept just because this branch now also calls `settle_questions`.
4654        assert_eq!(runner.state.status, RunStatus::Blocked);
4655        assert!(
4656            store.get(&q.id).unwrap().status.open(),
4657            "Blocked is still alive; settle_questions must have been a no-op here"
4658        );
4659    }
4660
4661    fn state_with_round(round: ReviewRound) -> RunState {
4662        let mut s = RunState::new(
4663            PathBuf::from("/repo"),
4664            "main".to_owned(),
4665            "abc1234".to_owned(),
4666            "add retries".to_owned(),
4667            Config::default(),
4668        );
4669        s.reviews = vec![round];
4670        s
4671    }
4672
4673    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
4674        crate::verdict::Finding {
4675            id: id.to_owned(),
4676            severity,
4677            file: None,
4678            line: None,
4679            title: title.to_owned(),
4680            detail: String::new(),
4681        }
4682    }
4683
4684    #[test]
4685    fn pr_body_names_open_findings_and_declined_ones() {
4686        let round = ReviewRound {
4687            round: 2,
4688            head: "deadbee".to_owned(),
4689            verified_head: None,
4690            reviews: vec![ReviewRecord {
4691                reviewer: 1,
4692                agent: "alpha".to_owned(),
4693                summary: String::new(),
4694                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
4695                vote: None,
4696                failed: None,
4697                duration_ms: 0,
4698            }],
4699            e2e: vec![CommandOutcome {
4700                command: "cargo test".to_owned(),
4701                code: Some(0),
4702                output_tail: String::new(),
4703                duration_ms: 0,
4704            }],
4705            verify_retried: false,
4706            e2e_deferred: false,
4707            e2e_defer_reason: None,
4708            fix: Some(FixRecord {
4709                agent: "alpha".to_owned(),
4710                addressed: Vec::new(),
4711                rejected: vec![crate::verdict::Rejection {
4712                    id: "R1-1-1".to_owned(),
4713                    why: "not reachable from any caller".to_owned(),
4714                }],
4715                notes: String::new(),
4716                committed: true,
4717                failed: None,
4718                duration_ms: 0,
4719            }),
4720            blocking: 0,
4721            answered: 1,
4722            expected: 1,
4723            clean: false,
4724            progressed: true,
4725            vote_split: false,
4726            reconsideration: Vec::new(),
4727            verdict: None,
4728        };
4729        let state = state_with_round(round);
4730        let body = pr_body(&state, 'A');
4731
4732        assert!(body.contains("add retries"), "the task must still be there");
4733        assert!(body.contains("R2-1-1"), "{body}");
4734        assert!(body.contains("unused import"), "{body}");
4735        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
4736        assert!(
4737            body.contains("not reachable from any caller"),
4738            "the reason it was declined: {body}"
4739        );
4740    }
4741
4742    #[test]
4743    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
4744        let round = ReviewRound {
4745            round: 1,
4746            head: "deadbee".to_owned(),
4747            verified_head: None,
4748            reviews: vec![ReviewRecord {
4749                reviewer: 1,
4750                agent: "alpha".to_owned(),
4751                summary: String::new(),
4752                findings: Vec::new(),
4753                vote: None,
4754                failed: None,
4755                duration_ms: 0,
4756            }],
4757            e2e: Vec::new(),
4758            verify_retried: false,
4759            e2e_deferred: false,
4760            e2e_defer_reason: None,
4761            fix: None,
4762            blocking: 0,
4763            answered: 1,
4764            expected: 1,
4765            clean: true,
4766            progressed: false,
4767            vote_split: false,
4768            reconsideration: Vec::new(),
4769            verdict: None,
4770        };
4771        let state = state_with_round(round);
4772        let body = pr_body(&state, 'A');
4773        assert!(!body.contains("Open review findings"), "{body}");
4774        assert!(!body.contains("Declined"), "{body}");
4775    }
4776
4777    #[test]
4778    fn pr_body_titles_itself_from_the_task_not_run_or_candidate() {
4779        let state = RunState::new(
4780            PathBuf::from("/repo"),
4781            "main".to_owned(),
4782            "abc1234".to_owned(),
4783            "add retries".to_owned(),
4784            Config::default(),
4785        );
4786        let body = pr_body(&state, 'A');
4787        let title = body.lines().next().unwrap();
4788
4789        assert_eq!(
4790            title, "add retries",
4791            "the title must be the task, not run/candidate bookkeeping: {body}"
4792        );
4793        assert!(
4794            body.contains(&format!("magi:run/{}", state.id)),
4795            "the run id must still be recoverable from the footer: {body}"
4796        );
4797        assert!(
4798            body.contains("magi:candidate-a"),
4799            "the candidate must still be recoverable from the footer: {body}"
4800        );
4801    }
4802
4803    #[test]
4804    fn pr_body_never_titles_itself_off_a_blank_first_line() {
4805        let leading_blank = RunState::new(
4806            PathBuf::from("/repo"),
4807            "main".to_owned(),
4808            "abc1234".to_owned(),
4809            "\n\n  \nadd retries\n\ndetails".to_owned(),
4810            Config::default(),
4811        );
4812        let body = pr_body(&leading_blank, 'A');
4813        assert_eq!(
4814            body.lines().next(),
4815            Some("add retries"),
4816            "a leading blank line must not become an empty title: {body}"
4817        );
4818
4819        let whitespace_only = RunState::new(
4820            PathBuf::from("/repo"),
4821            "main".to_owned(),
4822            "abc1234".to_owned(),
4823            "   \n  \n".to_owned(),
4824            Config::default(),
4825        );
4826        let body = pr_body(&whitespace_only, 'A');
4827        let title = body.lines().next().unwrap_or_default();
4828        assert!(
4829            !title.is_empty(),
4830            "a whitespace-only instruction must still fall back to a non-empty title: {body}"
4831        );
4832    }
4833
4834    #[test]
4835    fn manual_merge_command_matches_the_configured_style() {
4836        let repo = Path::new("/repo");
4837        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
4838
4839        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
4840        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
4841
4842        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
4843        assert_eq!(
4844            squash,
4845            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
4846             \"Merge magi run 0832 (candidate A)\""
4847        );
4848
4849        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
4850        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
4851    }
4852
4853    #[test]
4854    fn a_nudge_gets_a_quarter_of_the_budget() {
4855        // The judge and implement budgets magi ships with.
4856        assert_eq!(retry_budget(secs(1200), true), secs(300));
4857        assert_eq!(retry_budget(secs(3600), true), secs(900));
4858    }
4859
4860    #[test]
4861    fn a_resent_prompt_keeps_the_whole_budget() {
4862        // The seat kept no context, so the retry is the original job again and
4863        // shortening it would only guarantee a second failure.
4864        assert_eq!(retry_budget(secs(1200), false), secs(1200));
4865        assert_eq!(retry_budget(secs(60), false), secs(60));
4866    }
4867
4868    #[test]
4869    fn the_floor_never_exceeds_the_original_budget() {
4870        // A short configured timeout must not be *raised* by the floor: the
4871        // operator asked for a bound, and a retry may not outlast the attempt
4872        // it is retrying.
4873        assert_eq!(retry_budget(secs(60), true), secs(60));
4874        assert_eq!(retry_budget(secs(480), true), secs(120));
4875        assert_eq!(retry_budget(secs(0), true), secs(0));
4876    }
4877}