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::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36    ResolvedRoles,
37};
38use crate::git;
39use crate::land;
40use crate::proc::Quiet as _;
41use crate::prompt::{
42    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
43};
44use crate::queue;
45use crate::refs;
46use crate::run::{
47    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
48    DeliberationRound, DeliberationTurn, E2eStatus, FixRecord, GateFixRecord, Handover, JobRecord,
49    JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest,
50    Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, Tally,
51    VoteRecord, tail, write_artifact,
52};
53use crate::verdict::{
54    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
55    ReviewVote, Severity,
56};
57
58/// How much verification output is kept and fed back to the fixer.
59const OUTPUT_TAIL: usize = 8_000;
60
61/// Bytes of a failing command's output kept in an event, so the reason a run
62/// stopped is readable from the report without opening `run.json`.
63const EVENT_OUTPUT_TAIL: usize = 2_000;
64
65/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
66/// command's pid before releasing the build cache's lease.
67const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
68
69/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
70/// command's pid to actually exit before giving up and releasing anyway.
71///
72/// A timeout means the process was asked to die (`kill_on_drop`,
73/// `start_kill`), not that it already has — on Windows in particular that can
74/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
75/// the instant the command returns would let the very next acquirer (this
76/// run's own next round, another run's verification, the janitor's prune)
77/// start touching the same directory while it might still be writing to it,
78/// so this polls the actual pid — real confirmation, not a fixed guess —
79/// until it is gone or this ceiling is reached. It is still not full
80/// process-tree reaping: a grandchild the timed-out process spawned and that
81/// outlives it independently is invisible to a pid check, and continuing to
82/// observe and collect *that* stays a different piece of work with its own
83/// owner. Set generously because the common case returns early the moment
84/// the pid is confirmed gone, not because every timeout pays this in full.
85const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
86
87/// Consecutive review rounds with no tree progress (see
88/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
89/// instead of spending the rest of the round budget.
90///
91/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
92/// legitimately finds nothing left to change (its previous round's fix already
93/// covered it, and this round's reviewers re-raised only nits) looks the same
94/// as one that is spinning, for exactly one round. Two in a row is where the
95/// two stop being distinguishable, and a review round on this workload has
96/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
97/// third attempt at a tree that has not moved twice running is pure cost.
98/// This does not touch `review_rounds` itself, which stays the operator's
99/// call.
100pub(crate) const STAGNANT_LIMIT: usize = 2;
101
102/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
103/// a base that moved before giving up and leaving the run `Blocked` for a
104/// person.
105///
106/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
107/// that keeps moving faster than a run can catch it is not something more
108/// rebasing fixes, it is a person's call. Not the same *number as*
109/// `land_rounds` - this budget is spent before a pull request exists, land's
110/// after - but bounded for the identical reason, so it uses the same
111/// default. Counted across both call sites in [`Runner::finish_after_tally`]
112/// (once before review, once before the gate), because either one finding
113/// the base still moving is the same signal.
114const BASE_SYNC_ROUNDS: usize = 4;
115
116/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
117/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
118/// reply held no [`FixReport`].
119///
120/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
121/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
122/// "I'll pause here until the `cargo make check` background run reports
123/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
124/// No `FixReport` was ever collected from that seat, and the run moved on to
125/// the next review round regardless.
126///
127/// Bounded independently of `review_rounds` and `graph.retries`: this
128/// recovers one seat's missing report mid-round, not a new round of review or
129/// an ordinary parse retry, and must not itself become the unbounded wait the
130/// rest of this module exists to avoid.
131const MAX_FIX_CONTINUATIONS: usize = 2;
132
133/// One queued agent invocation.
134///
135/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
136/// CLI hung up on its own stream is asked again from the same job rather than
137/// rebuilt from scratch. See [`Runner::resume_undelivered`].
138#[derive(Clone)]
139struct SeatJob {
140    spec: AgentSpec,
141    seat: SeatState,
142    cwd: PathBuf,
143    prompt: String,
144    timeout: Duration,
145    allow_write: bool,
146    sessions: bool,
147    artifacts: PathBuf,
148    stem: String,
149}
150
151/// How the graph reads one agent invocation.
152///
153/// Quota is split out from an ordinary failure on purpose: a rate-limited call
154/// is known to fail again if retried now, so the retry loop must not spend an
155/// attempt on it. `Dropped` is split out for the opposite reason: unlike
156/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
157/// error JSON, never the agent's answer — a caller that matched only
158/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
159/// left to read that JSON as if it were usable output. `resume_undelivered`
160/// is the only caller that acts on it; everywhere else it is reported like an
161/// ordinary failure.
162enum AgentOutcome {
163    /// A usable output.
164    Ok(AgentOutput),
165    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
166    Quota(AgentOutput),
167    /// The CLI hung up on its own stream after billed work. See
168    /// [`agent::AgentOutput::work_undelivered`].
169    Dropped(AgentOutput),
170    /// Any other failure: a timeout, a bad exit code, an empty reply.
171    Failed(String),
172}
173
174/// A request to park the run at its next node boundary.
175///
176/// Cloning is how the request travels: the loop keeps one handle and hands a
177/// clone to each [`Runner`], and every clone points at the same flag. There
178/// is no channel because there is nothing to send - the only message is
179/// "park", it is idempotent, and a flag cannot be missed by a receiver that
180/// was not listening yet.
181///
182/// The boundary is what makes this cheap. Every node writes the run's state
183/// before the next one starts, and every node skips what is already recorded:
184/// `prep` returns early once candidates exist, `implement` asks only the seats
185/// with nothing on disk, `judge` returns early once judgements exist. So a
186/// parked run resumes into exactly the node it stopped before, and no agent
187/// work is thrown away. Killing the process mid-node, by contrast, loses
188/// whatever the seats in flight had not yet written - which for an implement
189/// wave is an hour of paid work.
190///
191/// A [`Runner`] watches two independent handles of this type - see
192/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
193/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
194/// clone covering the whole daemon's lifetime and is never asked to un-park,
195/// which is correct exactly because nothing is dispatched after it fires.
196/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
197/// that parks for an interrupted task must go on to run other tasks
198/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
199/// reusing the daemon-wide one.
200#[derive(Debug, Clone, Default)]
201pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
202
203impl Pause {
204    /// A pause nobody has asked for yet.
205    #[must_use]
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Ask the run to park at its next node boundary. Idempotent.
211    pub fn park(&self) {
212        self.0.store(true, Ordering::SeqCst);
213    }
214
215    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
216    /// fold into the run's own `park` event - so an operator reading the run
217    /// later knows this was a deliberate interrupt rather than a shutdown or
218    /// a binary swap. The first reason recorded wins; a park already in
219    /// flight is not relabelled by a second, unrelated request.
220    pub fn park_because(&self, reason: impl Into<String>) {
221        let mut reason_guard = self
222            .1
223            .lock()
224            .unwrap_or_else(std::sync::PoisonError::into_inner);
225        if reason_guard.is_none() {
226            *reason_guard = Some(reason.into());
227        }
228        drop(reason_guard);
229        self.park();
230    }
231
232    /// Has a park been asked for?
233    #[must_use]
234    pub fn parked(&self) -> bool {
235        self.0.load(Ordering::SeqCst)
236    }
237
238    /// Why the park was asked for, when the caller used [`Pause::park_because`].
239    #[must_use]
240    pub fn reason(&self) -> Option<String> {
241        self.1
242            .lock()
243            .unwrap_or_else(std::sync::PoisonError::into_inner)
244            .clone()
245    }
246}
247
248/// Drives one run.
249pub struct Runner {
250    /// Run state; public so the CLI can report on it.
251    pub state: RunState,
252    roles: ResolvedRoles,
253    sem: Arc<Semaphore>,
254    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
255    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
256    /// this is never the same handle as `interrupt`.
257    pause: Pause,
258    /// Set when `magi serve`'s interrupt scheduler wants this specific run
259    /// parked at its next node boundary, to let a task marked
260    /// [`crate::queue::Task::interrupt`] run alone before this one carries
261    /// on. Unlike `pause`, a fresh, unshared handle per run - see
262    /// [`Runner::watch_interrupt`].
263    interrupt: Pause,
264}
265
266/// The commit a run branches from: the base branch as the remote has it.
267///
268/// Two failures this replaces. A run used to branch off `HEAD` and so refused
269/// to start on a dirty tree, which made `magi serve` decline every task for as
270/// long as the operator had work in progress - most of the time. Branching off
271/// the *local* base branch fixed that and introduced a worse one: `land` merges
272/// the winner on GitHub, nothing updates the local ref, and the next run
273/// branches off a base missing everything the previous runs landed. Two tasks
274/// in a row from a phone would have had the second silently re-implementing
275/// against stale code and opening a pull request that reverted the first.
276///
277/// Only refs move here - no checkout, no local branch, no merge - so it is safe
278/// with uncommitted work in the tree. A machine with no network still starts:
279/// the fetch may fail and the local tip is used with a warning, because
280/// refusing to run offline is a worse failure than running against a base the
281/// operator can see for themselves.
282///
283/// One function, called by both entry points. Two answers to "where does a run
284/// branch from" is the kind of drift nobody notices until a diff is wrong.
285/// Bring the local `branch` in line with `<remote>/<branch>` before a review
286/// checks it out.
287///
288/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
289/// anything other than plain `git push` from this checkout (a jj colocated
290/// workspace, another clone) moves only the remote-tracking ref - so the local
291/// one can be a stale placeholder. It moves only when local is behind the remote or is an
292/// empty placeholder that diverged from it; unpushed local work is kept, and a real
293/// divergence is refused rather than guessed at.
294async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
295    let tracking = format!("{remote}/{branch}");
296    let fetched = git::fetch(repo, remote, branch).await;
297    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
298    let local_exists = git::branch_exists(repo, branch).await?;
299    if !fresh {
300        if !local_exists {
301            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
302        }
303        tracing::warn!(
304            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
305        );
306        return Ok(());
307    }
308    let remote_sha = git::rev_parse(repo, &tracking).await?;
309    if !local_exists {
310        git::git(repo, &["branch", branch, &tracking]).await?;
311        return Ok(());
312    }
313    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
314    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
315        return Ok(());
316    }
317    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
318        // Diverged. `reconcile` settles it only when it can prove nothing is
319        // lost: a local tip that is the remote's change rebased is pushed over
320        // it (lease pinned to the tip read here), a tip whose every extra
321        // commit is empty is a placeholder the remote's work replaced, and
322        // anything else is two different changes - a question for a person.
323        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
324            .await?
325        {
326            crate::reconcile::Reconciliation::Pushed => {
327                tracing::warn!(
328                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
329                    short(&local_sha),
330                    short(&remote_sha)
331                );
332                return Ok(());
333            }
334            crate::reconcile::Reconciliation::Placeholder => {}
335            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
336        }
337    }
338    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
339    if !out.ok() {
340        bail!(
341            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
342            short(&local_sha),
343            short(&remote_sha),
344            out.stderr
345        );
346    }
347    tracing::warn!(
348        "local `{branch}` was stale: fast-forwarded {} -> {}",
349        short(&local_sha),
350        short(&remote_sha)
351    );
352    Ok(())
353}
354
355async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
356    let tracking = format!("{remote}/{base_branch}");
357    let fetched = git::fetch(repo, remote, base_branch).await;
358    if let Ok(out) = &fetched
359        && out.ok()
360        && git::rev_exists(repo, &tracking).await
361    {
362        return git::rev_parse(repo, &tracking).await;
363    }
364    let why = match &fetched {
365        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
366        Ok(_) => format!("{remote} has no {base_branch}"),
367        Err(e) => e.to_string(),
368    };
369    tracing::warn!(
370        "could not read {tracking} ({why}); branching off the local \
371         {base_branch} instead, which may be behind"
372    );
373    git::rev_parse(repo, base_branch).await.with_context(|| {
374        format!(
375            "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
376             branch that exists"
377        )
378    })
379}
380
381/// Exclusive claim on one run's `magi fix` step, released on drop — including
382/// on an early return or a panic.
383///
384/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
385/// manual `magi fix` invocations against the same run are otherwise
386/// invisible to each other and would race to remove and recreate the same
387/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
388/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
389/// which is only ever reclaimed later, out of band, by
390/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
391/// `magi fix` invocation is not necessarily running under either of those, so
392/// nothing would ever sweep a lock a killed or crashed process left behind.
393/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
394/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
395/// lease use: an unreadable or unparsable pid, or a liveness query the
396/// platform cannot answer, reads as alive and the lock is left in place.
397struct FixClaim {
398    path: PathBuf,
399}
400
401impl FixClaim {
402    fn acquire(dir: &Path) -> Result<Self> {
403        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
404        let path = dir.join("fix.lock");
405        match Self::create(&path) {
406            Ok(claim) => Ok(claim),
407            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
408                if Self::reclaim_if_dead(&path) {
409                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
410                } else {
411                    bail!(
412                        "another `magi fix` is already running for this run ({} exists)",
413                        path.display()
414                    )
415                }
416            }
417            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
418        }
419    }
420
421    fn create(path: &Path) -> std::io::Result<Self> {
422        let mut f = std::fs::OpenOptions::new()
423            .write(true)
424            .create_new(true)
425            .open(path)?;
426        use std::io::Write as _;
427        // Read back by `reclaim_if_dead` on a later, stuck invocation.
428        writeln!(f, "{}", std::process::id())?;
429        Ok(Self {
430            path: path.to_owned(),
431        })
432    }
433
434    /// True if the lock named a process confirmed dead, in which case it was
435    /// also removed. Never true on an unreadable file, an unparsable pid, or
436    /// a liveness query the platform cannot answer — see this type's own doc.
437    fn reclaim_if_dead(path: &Path) -> bool {
438        let dead = std::fs::read_to_string(path)
439            .ok()
440            .and_then(|body| body.trim().parse::<u32>().ok())
441            .is_some_and(|pid| !crate::proc::pid_alive(pid));
442        dead && std::fs::remove_file(path).is_ok()
443    }
444}
445
446impl Drop for FixClaim {
447    fn drop(&mut self) {
448        let _ = std::fs::remove_file(&self.path);
449    }
450}
451
452impl Runner {
453    /// Start a fresh run against `repo`.
454    pub async fn start(
455        repo: &Path,
456        instruction: String,
457        config: Config,
458        origin: Origin,
459    ) -> Result<Self> {
460        Self::start_naming(repo, instruction, "", config, origin).await
461    }
462
463    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
464    /// searched for branch and commit references along with the instruction,
465    /// since a task may name the work it is about only in its title.
466    pub async fn start_naming(
467        repo: &Path,
468        instruction: String,
469        also_scan: &str,
470        config: Config,
471        origin: Origin,
472    ) -> Result<Self> {
473        let repo = git::toplevel(repo).await?;
474        let missing = agent::missing_programs(&config.agents);
475        if !missing.is_empty() {
476            bail!(
477                "these agent programs are not on PATH: {}. Fix the roster in \
478                 magi.toml or install them.",
479                missing.join(", ")
480            );
481        }
482        let base_branch = match config.merge.base.clone() {
483            Some(b) => b,
484            None => git::current_branch(&repo)
485                .await?
486                .context("HEAD is detached; set [merge] base in magi.toml")?,
487        };
488        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
489        // Still worth saying out loud. The operator's uncommitted work is not
490        // part of this run, and someone watching a candidate fail to use a
491        // change they just made deserves to know why.
492        if !git::is_clean(&repo).await? {
493            tracing::warn!(
494                "{} has uncommitted changes; they are not part of this run, \
495                 which branches off {base_branch} ({})",
496                repo.display(),
497                &base_commit[..base_commit.len().min(8)]
498            );
499        }
500        let roles = config.resolve_roles()?;
501        let max_parallel = config.graph.max_parallel.max(1);
502        // A task that points at work already in the repository starts from
503        // it; what the repository says about each reference is recorded.
504        let seeds = refs::resolve(
505            &repo,
506            &base_commit,
507            &config.merge.remote,
508            &format!("{also_scan}\n{instruction}"),
509        )
510        .await;
511        refs::plan(&repo, &seeds).await?;
512        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
513        // Recorded before the first save, so a crash right after minting
514        // cannot leave a run with no origin. Legibility only: nothing reads it
515        // to decide anything.
516        state.origin = Some(origin);
517        for seed in &seeds {
518            state.event(
519                "seed",
520                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
521            );
522        }
523        state.seeds = seeds;
524        state.event("start", format!("run {} created", state.id));
525        state.save()?;
526        Ok(Self {
527            state,
528            roles,
529            sem: Arc::new(Semaphore::new(max_parallel)),
530            pause: Pause::new(),
531            interrupt: Pause::new(),
532        })
533    }
534
535    /// Open a review-only run against work that already exists on `branch`.
536    ///
537    /// The expensive half of the graph is the implement wave — measured at
538    /// 111 and 134 internal tool-loop turns on this repository, against a
539    /// handful for a judge or a reviewer. The cheap half is worth running on
540    /// hand-written work too, and there was no way to reach it.
541    ///
542    /// No new state and no schema change are needed: a run with **one** viable
543    /// candidate and a tally already decided degrades `execute` to exactly
544    /// review → gate → merge, because `judge` skips a single-candidate field,
545    /// `deliberate` has fewer than two first choices to reconcile, `vote`
546    /// returns early, `tally` is already present and `fold_losers` has no
547    /// losers. Resuming such a run therefore does the right thing as well.
548    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
549        Self::review_taking_over(repo, branch, config, None, origin).await
550    }
551
552    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
553    /// at the same task still has `branch` checked out, its worktree is
554    /// released first if that is safe (see [`crate::handover`]), and the
555    /// review refuses with the reason if it is not. `None` is a hand-run
556    /// review: it has no earlier attempts, so only a worktree of a dead run
557    /// magi recorded itself can be released.
558    pub async fn review_taking_over(
559        repo: &Path,
560        branch: &str,
561        config: Config,
562        takeover: Option<crate::handover::Takeover>,
563        origin: Origin,
564    ) -> Result<Self> {
565        let repo = git::toplevel(repo).await?;
566        let missing = agent::missing_programs(&config.agents);
567        if !missing.is_empty() {
568            bail!(
569                "these agent programs are not on PATH: {}. Fix the roster in \
570                 magi.toml or install them.",
571                missing.join(", ")
572            );
573        }
574        let base_branch = match config.merge.base.clone() {
575            Some(b) => b,
576            None => git::current_branch(&repo)
577                .await?
578                .context("HEAD is detached; set [merge] base in magi.toml")?,
579        };
580        if base_branch == branch {
581            bail!("`{branch}` is the base branch; there is nothing to review against");
582        }
583        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
584
585        let roles = config.resolve_roles()?;
586        let max_parallel = config.graph.max_parallel.max(1);
587        let mut state = RunState::new(
588            repo.clone(),
589            base_branch,
590            base_commit.clone(),
591            String::new(),
592            config,
593        );
594        state.origin = Some(origin);
595
596        // Released before anything else touches the branch: a stale local
597        // branch is moved with `git branch -f`, which git refuses while an
598        // earlier attempt's worktree still has it checked out. Everything
599        // after this point that can fail puts the old run back.
600        // A hand-run review has no task, hence no earlier attempts, but a
601        // worktree of a dead run magi made may still be released.
602        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
603            earlier: Vec::new(),
604            home: crate::run::home(),
605            choice: None,
606        });
607        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
608        if let Some(released) = &released {
609            state.event(
610                "release",
611                format!(
612                    "took `{branch}` over from run {}: its worktree was released: {}",
613                    crate::run::short_of(&released.old_id),
614                    released.audit
615                ),
616            );
617        }
618        // The owner's answer to an earlier divergence question is applied
619        // here: after the release (git will not move a checked-out branch)
620        // and before the sync that would otherwise ask again.
621        if let Some(choice) = takeover.choice.as_ref()
622            && let Err(e) =
623                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
624                    .await
625        {
626            if let Some(released) = &released {
627                released.restore(&repo, branch).await;
628            }
629            return Err(e.context("applying the owner's answer about the diverged branch"));
630        }
631        let opened =
632            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
633        if opened.is_err()
634            && let Some(released) = &released
635        {
636            released.restore(&repo, branch).await;
637        }
638        opened
639    }
640
641    /// The half of [`Runner::review_taking_over`] that can fail after an
642    /// earlier attempt's worktree was released.
643    async fn open_review(
644        repo: &Path,
645        branch: &str,
646        mut state: RunState,
647        roles: ResolvedRoles,
648        max_parallel: usize,
649        base_commit: String,
650    ) -> Result<Self> {
651        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
652        // The commit subjects are the closest thing to a task statement that
653        // existing work carries, and the reviewers are told as much.
654        let log = git::log_oneline(repo, &base_commit, branch)
655            .await
656            .unwrap_or_default();
657        let instruction = format!(
658            "Review the work already on branch `{branch}`. There is no task \
659             statement: what the change claims to do is whatever its commits \
660             say.\n\n{}",
661            if log.trim().is_empty() {
662                "(no commit messages)"
663            } else {
664                log.trim()
665            }
666        );
667        state.instruction = instruction;
668        state.reviewed_commits = Some(
669            git::subjects(repo, &base_commit, branch)
670                .await
671                .unwrap_or_default(),
672        );
673
674        // An attached worktree, so the fixer's commits land on the branch under
675        // review rather than on a detached head nobody will look at again.
676        let worktree = state.worktree_root().join("under-review");
677        if let Some(parent) = worktree.parent() {
678            tokio::fs::create_dir_all(parent).await.ok();
679        }
680        let path = worktree.to_string_lossy().to_string();
681        git::git(repo, &["worktree", "add", &path, branch])
682            .await
683            .with_context(|| {
684                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
685            })?;
686
687        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
688            .await
689            .unwrap_or(0);
690        if commits == 0 {
691            git::worktree_remove(repo, &worktree).await.ok();
692            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
693        }
694        let files = git::changed_files(&worktree, &base_commit, "HEAD")
695            .await
696            .map(|f| f.len())
697            .unwrap_or(0);
698        if files == 0
699            && let (Ok(head_tree), Ok(base_tree)) = (
700                git::tree_of(&worktree, "HEAD").await,
701                git::tree_of(&worktree, &base_commit).await,
702            )
703            && head_tree == base_tree
704        {
705            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
706            git::worktree_remove(repo, &worktree).await.ok();
707            bail!(
708                "`{branch}` at {} has a tree identical to base {}; this usually means \
709                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
710                 against `{}/{branch}`) rather than an empty change",
711                short(&head),
712                short(&base_commit),
713                state.config.merge.remote
714            );
715        }
716        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
717            .await
718            .unwrap_or_default();
719
720        state.candidates.push(Candidate {
721            index: 0,
722            label: 'A',
723            // Not an agent id on purpose: nothing in the roster wrote this, and
724            // the stats tables must not credit anyone with a win for it.
725            agent: EXISTING_BRANCH.to_owned(),
726            branch: branch.to_owned(),
727            worktree,
728            summary: String::new(),
729            stat,
730            files,
731            commits,
732            empty: false,
733            failed: None,
734            verified_noop: None,
735            duration_ms: 0,
736            folded: false,
737        });
738        state.tally = Some(Tally {
739            first_choice: BTreeMap::from([('A', 0)]),
740            borda: BTreeMap::new(),
741            winner: 'A',
742            rankings: 0,
743            unanimous_initial: false,
744            deliberated: false,
745            changed_votes: 0,
746            unanimous_final: false,
747            tie_break: None,
748            // No panel sat, so no quorum applies. Zero judges is the correct
749            // number for work that never competed, and must not be reported as
750            // a collapsed panel.
751            judges: 0,
752            present: 0,
753            quorum: 0,
754            met_quorum: true,
755            uncontested: Some("review-only run: nothing competed".to_owned()),
756        });
757        state.status = RunStatus::Reviewing;
758        state.event(
759            "start",
760            format!(
761                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
762                state.id
763            ),
764        );
765        state.save()?;
766        Ok(Self {
767            state,
768            roles,
769            sem: Arc::new(Semaphore::new(max_parallel)),
770            pause: Pause::new(),
771            interrupt: Pause::new(),
772        })
773    }
774
775    /// Reopen an existing run.
776    pub fn resume(id: &str) -> Result<Self> {
777        let state = RunState::load(id)?;
778        if let Some(to) = &state.released_to {
779            bail!(
780                "run {} cannot be resumed: its worktree was released to run {}",
781                state.short(),
782                crate::run::short_of(to)
783            );
784        }
785        let roles = state.config.resolve_roles()?;
786        let max_parallel = state.config.graph.max_parallel.max(1);
787        Ok(Self {
788            state,
789            roles,
790            sem: Arc::new(Semaphore::new(max_parallel)),
791            pause: Pause::new(),
792            interrupt: Pause::new(),
793        })
794    }
795
796    /// Walk the graph to a terminal state, skipping nodes already recorded.
797    ///
798    /// Every way a run is driven - the queue loop, `magi run`, a resume from
799    /// the phone - ends here, so this is the one place a run that ended
800    /// Blocked / Stalled / Failed, or died with an error, is announced to the
801    /// notification centre. Best-effort: see [`crate::notices::raise`].
802    pub async fn execute(&mut self) -> Result<()> {
803        let result = self.execute_graph().await;
804        self.mark_driver_exited();
805        let ended = if result.is_err() {
806            Some(crate::notices::run_stopped(&self.state.id, &self.state))
807        } else {
808            crate::notices::run_ended(&self.state)
809        };
810        if let Some(notice) = ended {
811            crate::notices::raise(notice);
812        }
813        result
814    }
815
816    /// Record that this process no longer drives the run, so its pid (a
817    /// daemon's outlives the run) is not read as a live driver.
818    ///
819    /// Written onto the record as it is on disk, never this copy: another
820    /// process may have resumed the run (recording its own pid and clearing
821    /// the flag) or released its worktree since this copy was read, and
822    /// saving over that would mark a running driver dead. Only a record still
823    /// naming this process as the driver is touched.
824    fn mark_driver_exited(&mut self) {
825        self.state.driver_exited = true;
826        let pid = std::process::id();
827        let Ok(mut disk) = RunState::load(&self.state.id) else {
828            return;
829        };
830        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
831            return;
832        }
833        disk.driver_exited = true;
834        if let Err(e) = disk.save() {
835            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
836        }
837    }
838
839    async fn execute_graph(&mut self) -> Result<()> {
840        // Moving again, so it is no longer parked. Set before the walk rather
841        // than in `resume`, so every way of re-entering the graph clears it
842        // and a card cannot claim a run is waiting to be resumed while the
843        // agents are already working.
844        self.state.parked = false;
845        // Any seat this state still lists as answering belongs to whatever
846        // process last drove this run — this one included, if it crashed
847        // mid-wave. Cleared and flushed immediately, before anything else
848        // runs, so a resume can never show a seat as live when nothing is
849        // asking it anything yet; the node that actually dispatches the next
850        // wave repopulates it.
851        self.state.clear_active();
852        // Recorded in the same spot, and flushed together with the clear
853        // above: this is the pid a reader checks (`RunState::liveness`) when
854        // no daemon claim exists to answer "is a process still driving this
855        // run" — a plain `magi run` / `magi review` typed into a terminal
856        // claims nothing there. Always overwritten, never only-if-absent, so
857        // a resumed run's stale pid from a previous, possibly-dead process
858        // can never survive into this one's own report. Unlike
859        // `clear_active`, this changes on every single `execute()` call, so
860        // the save below is now unconditional rather than only-if-cleared.
861        //
862        // `driver_started_at` is recorded in the same breath, from this same
863        // pid, so `liveness` can tell a live pid that is genuinely still us
864        // apart from one the OS has since handed to an unrelated process —
865        // see that field's own doc for why the pid alone is not enough.
866        // A resume that raced a takeover: the record on disk says the worktree
867        // was handed to a later run after this copy was read. Saving over it
868        // would erase that and drive a run with nothing to run in.
869        if let Ok(disk) = RunState::load(&self.state.id)
870            && let Some(to) = &disk.released_to
871        {
872            bail!(
873                "run {} cannot continue: its worktree was released to run {}",
874                self.state.short(),
875                crate::run::short_of(to)
876            );
877        }
878        let pid = std::process::id();
879        self.state.driver_pid = Some(pid);
880        self.state.driver_started_at = crate::proc::process_started_at(pid);
881        self.state.driver_exited = false;
882        self.state.save()?;
883        // A run that already lost its quorum never resumes into the verdict
884        // machinery: `deliberate` and `vote` would otherwise clobber the
885        // stalled marker back to Voting and the run would keep going past a
886        // verdict that is no longer trustworthy. Everything already recorded is
887        // kept, so the run stays resumable (or foldable) for a human to pick up.
888        //
889        // On --resume the run gets one chance to repair itself: the seats a
890        // rate limit took out are re-asked. If their quota has since reset and
891        // the quorum is restored, the run picks up and finishes; otherwise it
892        // stays stale and still-resumable for a later retry. If it does not
893        // recover, the returned status stays `Stalled` and nothing was
894        // clobbered (the recovery only mutates entries for the lost seats).
895        if self.state.status == RunStatus::Stalled {
896            if self.recover_stall().await? {
897                self.finish_after_tally().await?;
898            } else {
899                // Still below quorum: persist the marker and stay resumable.
900                self.state.save()?;
901            }
902            return Ok(());
903        }
904        // A run parked inside `land` - watching CI, mid fix-round, or
905        // waiting on the owner's merge approval - resumes directly into it,
906        // never back through `prep`. Everything before `merge` already
907        // concluded; that is the only way `status` reaches `Landing` in the
908        // first place. Re-walking `review_loop` first would also be actively
909        // wrong: its own status recomputation (see its doc) treats any
910        // clean round as reason to set `status` to `Gating`, which would
911        // clobber this marker before `merge` ever ran, and this run would
912        // never find its way back into `land` at all.
913        if self.state.status == RunStatus::Landing {
914            self.run_land().await?;
915            // `run_land` may have settled the run right here - CI came back
916            // green and the PR merged, say - without ever passing back
917            // through `merge`'s own trailing call. Whatever it left `status`
918            // as is what this has to read.
919            self.settle_questions();
920            return Ok(());
921        }
922        self.prep().await?;
923        if self.park_here()? {
924            return Ok(());
925        }
926        self.advise().await?;
927        if self.park_here()? {
928            return Ok(());
929        }
930        self.implement().await?;
931        if self.park_here()? {
932            return Ok(());
933        }
934        // `after_implement` already saved the state and settled any open
935        // questions when it set this; nothing later in the graph has
936        // anything to judge.
937        if self.state.status == RunStatus::VerifiedNoop {
938            return Ok(());
939        }
940        self.judge().await?;
941        if self.park_here()? {
942            return Ok(());
943        }
944        self.deliberate().await?;
945        if self.park_here()? {
946            return Ok(());
947        }
948        self.vote().await?;
949        if self.park_here()? {
950            return Ok(());
951        }
952        self.tally()?;
953        // A verdict that lost its quorum is not trustworthy: do not review,
954        // gate, or merge on it. Everything already done is kept, so the run
955        // stays resumable (or foldable); the human can replace the agent that
956        // ran out of quota and pick it up.
957        if self.state.status == RunStatus::Stalled {
958            // Persist the stalled marker now — the normal end-of-execute save
959            // below is below this early return, and without it a resumed run
960            // would reload a pre-tally status and keep going.
961            self.state.save()?;
962            return Ok(());
963        }
964        self.finish_after_tally().await?;
965        Ok(())
966    }
967
968    /// Park here if asked to, recording it in the run's own timeline.
969    ///
970    /// Returns whether the caller should stop walking the graph. The state is
971    /// saved either way by the node that just finished; this adds the event so
972    /// the operator's card says why a run that is neither finished nor moving
973    /// is sitting where it is.
974    fn park_here(&mut self) -> Result<bool> {
975        // Either handle asking is enough - see `Pause`'s own doc for why
976        // they are never the same one. `interrupt` is checked second so a
977        // reason it carries is preferred in the message below over a plain
978        // shutdown park racing it at the same boundary.
979        if !self.pause.parked() && !self.interrupt.parked() {
980            return Ok(false);
981        }
982        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
983            Some(reason) => format!(
984                "parked after `{}` ({reason}) — resume to carry on from here",
985                self.state.status.as_str()
986            ),
987            None => format!(
988                "parked after `{}` — resume to carry on from here",
989                self.state.status.as_str()
990            ),
991        };
992        self.state.event("park", why);
993        self.state.parked = true;
994        self.state.save()?;
995        Ok(true)
996    }
997
998    /// Hand the runner the pause `magi serve`'s own shutdown watches.
999    pub fn on_pause(&mut self, pause: Pause) {
1000        self.pause = pause;
1001    }
1002
1003    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1004    /// scheduler asking this one run - and no other - to park so a task
1005    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1006    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1007    /// handle.
1008    pub fn watch_interrupt(&mut self, pause: Pause) {
1009        self.interrupt = pause;
1010    }
1011
1012    /// Abandon this run's own open questions, once `status` has actually
1013    /// settled rather than merely paused.
1014    ///
1015    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1016    /// either back up with the candidates, the review round and the seat
1017    /// sessions already on disk, so a question an implementer asked mid-round
1018    /// may still get a real answer read by a real resume. Only the statuses
1019    /// `resumable` excludes are actually final: the run merged, it reached
1020    /// `Ready` with nothing left to do, it failed outright with no
1021    /// established point to continue from, or every candidate agreed, with
1022    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1023    /// every one of those the seat that asked is gone for good, exactly like
1024    /// the run being deleted under `magi run rm` - so the same cleanup
1025    /// applies, worded for what actually happened instead of "the run was
1026    /// deleted".
1027    ///
1028    /// Best-effort and silent on success: called from every place `status`
1029    /// can land on one of those three, including ones a resumed run revisits,
1030    /// so it must cost nothing when there was nothing open to begin with.
1031    fn settle_questions(&mut self) {
1032        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1033            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1034        }
1035    }
1036
1037    /// The tail of the graph after a trustworthy tally: fold losers, review,
1038    /// gate, merge, and persist.
1039    async fn finish_after_tally(&mut self) -> Result<()> {
1040        self.fold_losers().await?;
1041        // Before review starts, and again right before the gate: a run's
1042        // review rounds can themselves take long enough for the base to move
1043        // a second time, and the gate is the one node whose "green" gets
1044        // acted on.
1045        self.sync_to_base().await?;
1046        if self.state.status == RunStatus::AlreadyInBase {
1047            return Ok(());
1048        }
1049        self.review_loop().await?;
1050        self.sync_to_base().await?;
1051        if self.state.status == RunStatus::AlreadyInBase {
1052            return Ok(());
1053        }
1054        self.gate().await?;
1055        self.merge().await?;
1056        self.state.save()?;
1057        Ok(())
1058    }
1059
1060    // ---------------------------------------------------------------- prep
1061
1062    async fn prep(&mut self) -> Result<()> {
1063        if !self.state.candidates.is_empty() {
1064            return Ok(());
1065        }
1066        self.state.status = RunStatus::Prep;
1067        let repo = self.state.repo.clone();
1068        let base = self.state.base_commit.clone();
1069        let plan = refs::plan(&repo, &self.state.seeds).await?;
1070        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1071        let root = self.state.worktree_root();
1072        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1073
1074        // The hook is the write-time half of the blindness contract; the
1075        // presentation filter in `blind` is the half that cannot be bypassed.
1076        let hooks_dir = self.state.dir().join("hooks");
1077        if self.state.config.blind.commit_msg_hook {
1078            std::fs::create_dir_all(&hooks_dir)
1079                .with_context(|| format!("create {}", hooks_dir.display()))?;
1080            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1081            let path = hooks_dir.join("commit-msg");
1082            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1083            make_executable(&path)?;
1084            // Ref-counted rather than a plain idempotent set: with more than
1085            // one run able to be in flight in the same repository at once
1086            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1087            // true?" check cannot tell "another run of mine still needs
1088            // this" from "nobody does", and the run that happens to finish
1089            // first would disable the hook out from under a sibling still
1090            // relying on it.
1091            git::acquire_worktree_config(&repo).await?;
1092            self.state.enabled_worktree_config = true;
1093        }
1094
1095        for (index, (spec, label)) in self
1096            .roles
1097            .implementers
1098            .clone()
1099            .into_iter()
1100            .zip(labels)
1101            .enumerate()
1102        {
1103            let branch = self.state.branch_for(label);
1104            let worktree = root.join(format!("cand-{label}"));
1105            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1106            if self.state.config.blind.commit_msg_hook {
1107                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1108            }
1109            git::local_exclude(&worktree, "/.magi/").await?;
1110            for pick in &plan.picks {
1111                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1112                    self.state.status = RunStatus::Blocked;
1113                    self.state
1114                        .event("prep", format!("cannot apply referenced commit: {e}"));
1115                    self.state.save()?;
1116                    return Err(e);
1117                }
1118            }
1119            self.state.candidates.push(Candidate {
1120                index,
1121                label,
1122                agent: spec.id.clone(),
1123                branch,
1124                worktree,
1125                summary: String::new(),
1126                stat: String::new(),
1127                files: 0,
1128                commits: 0,
1129                empty: false,
1130                failed: None,
1131                verified_noop: None,
1132                duration_ms: 0,
1133                folded: false,
1134            });
1135        }
1136
1137        for j in 1..=self.roles.judges.len() {
1138            let wt = root.join(format!("judge-{j}"));
1139            if !wt.exists() {
1140                git::worktree_add_detached(&repo, &wt, &base).await?;
1141            }
1142        }
1143
1144        // Disposable, detached checkouts for the design-deliberation stage's
1145        // advisor seats — the same shape as the judges' above, at the same
1146        // base commit, since advisors also only ever read. Sized off the
1147        // configured count directly rather than a resolved roster: unlike
1148        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1149        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1150        // `prep` has no `ResolvedRoles` field to read a count from here.
1151        if self.state.config.graph.advise {
1152            for k in 1..=self.state.config.graph.advisors {
1153                let wt = root.join(format!("advisor-{k}"));
1154                if !wt.exists() {
1155                    git::worktree_add_detached(&repo, &wt, &base).await?;
1156                }
1157            }
1158        }
1159
1160        // A judge cannot tell it is looking at its own patch — the seats keep
1161        // separate conversations — but a panel that shares agents with the
1162        // field is less independent than it looks, and that is worth saying out
1163        // loud once per run rather than leaving it in the config.
1164        let authors: Vec<&str> = self
1165            .roles
1166            .implementers
1167            .iter()
1168            .map(|a| a.id.as_str())
1169            .collect();
1170        let overlap: Vec<String> = self
1171            .roles
1172            .judges
1173            .iter()
1174            .enumerate()
1175            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1176            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1177            .collect();
1178        if !overlap.is_empty() {
1179            let note = format!(
1180                "{} also authored a candidate; blind, but the panel is less \
1181                 independent than {} distinct agents would be",
1182                overlap.join(", "),
1183                self.roles.judges.len()
1184            );
1185            self.state.event("prep", note);
1186        }
1187
1188        self.state.event(
1189            "prep",
1190            format!(
1191                "{} candidates, {} judges, base {} ({})",
1192                self.state.candidates.len(),
1193                self.roles.judges.len(),
1194                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1195                self.state.base_branch
1196            ),
1197        );
1198        self.state.status = RunStatus::Implementing;
1199        self.state.save()?;
1200        Ok(())
1201    }
1202
1203    // -------------------------------------------------------------- advise
1204
1205    /// The design-deliberation stage: independent, read-only advisor seats
1206    /// each sketch a design before any implementer touches the repository,
1207    /// and (when at least one produced a usable proposal) a synthesis seat
1208    /// blends them into a brief `implement` carries in every candidate's
1209    /// prompt.
1210    ///
1211    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1212    /// advisors` is the proposal count. Everything here is best-effort and
1213    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1214    /// that cannot reach quota, or a synthesis seat that produced nothing
1215    /// usable all leave `implement` exactly as it was before this stage
1216    /// existed — the task instruction alone — rather than failing the whole
1217    /// competition over an enrichment stage. Every outcome is still recorded
1218    /// as an event, so a run that got nothing from this stage says why.
1219    ///
1220    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1221    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1222    /// resumed run whose stage failed would re-run it, and re-spend the
1223    /// agent calls, on every reentry before `implement`.
1224    ///
1225    /// Also skipped once any candidate shows implementation progress — the
1226    /// exact predicate `implement` itself uses to decide a candidate is no
1227    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1228    /// is not enough: a run created by an older binary that predates this
1229    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1230    /// an already-`Implementing`-or-later run under this build would
1231    /// otherwise walk straight back through `prep` (a no-op once candidates
1232    /// exist) into this node and spawn every advisor seat against worktrees
1233    /// `prep` never recreated — after implementation has already started,
1234    /// which is exactly the invariant this stage exists to guarantee.
1235    async fn advise(&mut self) -> Result<()> {
1236        let implement_untouched = self
1237            .state
1238            .candidates
1239            .iter()
1240            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1241        if !self.state.config.graph.advise || self.state.advise_attempted {
1242            return Ok(());
1243        }
1244        if !implement_untouched {
1245            self.state.event(
1246                "advise",
1247                "skipping the design-deliberation stage: at least one \
1248                 candidate already shows implementation progress, so this \
1249                 run is past the point the stage exists to run before"
1250                    .to_owned(),
1251            );
1252            self.state.advise_attempted = true;
1253            self.state.save()?;
1254            return Ok(());
1255        }
1256        let run_id = self.state.id.clone();
1257        let prompts = self.state.config.prompts.clone();
1258        let instruction = self.state.instruction.clone();
1259        let language = self.state.config.graph.language.clone();
1260        let root = self.state.worktree_root();
1261        let n = self.state.config.graph.advisors;
1262        let where_recorded = self.state.dir().join("run.json");
1263
1264        let seats = match self.state.config.advisors() {
1265            Ok(seats) if !seats.is_empty() => seats,
1266            Ok(_) => {
1267                self.state.event(
1268                    "advise",
1269                    format!(
1270                        "[graph] advisors is 0; skipping the design-deliberation \
1271                         stage and continuing without a synthesis brief (see {})",
1272                        where_recorded.display()
1273                    ),
1274                );
1275                self.state.advise_attempted = true;
1276                self.state.save()?;
1277                return Ok(());
1278            }
1279            Err(e) => {
1280                self.state.event(
1281                    "advise",
1282                    format!(
1283                        "could not resolve advisor seats ({e:#}); continuing \
1284                         without a design-deliberation brief (see {})",
1285                        where_recorded.display()
1286                    ),
1287                );
1288                self.state.advise_attempted = true;
1289                self.state.save()?;
1290                return Ok(());
1291            }
1292        };
1293
1294        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1295        let artifacts = agent::artifacts_dir(&self.state.dir());
1296        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1297
1298        let mut jobs = Vec::new();
1299        for (i, spec) in seats.iter().cloned().enumerate() {
1300            let seat_key = format!("advisor-{}", i + 1);
1301            let seat = self.seat(&seat_key, &spec.id);
1302            jobs.push(SeatJob {
1303                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1304                spec,
1305                seat,
1306                cwd: worktrees[i % worktrees.len()].clone(),
1307                timeout,
1308                allow_write: false,
1309                sessions: false,
1310                artifacts: artifacts.clone(),
1311                stem: seat_key,
1312            });
1313        }
1314
1315        self.state.event(
1316            "advise",
1317            format!(
1318                "{} advisor seat(s) sketching a design in parallel",
1319                jobs.len()
1320            ),
1321        );
1322        let mut quota_losses = Vec::new();
1323        let cache = self.state.config.cache_dir();
1324        let ctx = WaveCtx {
1325            run: &run_id,
1326            node: "advise",
1327            prompts: &prompts,
1328            cache: cache.as_deref(),
1329            round: None,
1330        };
1331        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1332        let results = ask_json_wave::<Proposal>(
1333            jobs,
1334            Arc::clone(&self.sem),
1335            self.state.config.graph.retries,
1336            &advisor_roster,
1337            &ctx,
1338            &mut quota_losses,
1339            &mut self.state,
1340            &|p: &Proposal| p.validate(),
1341        )
1342        .await;
1343        self.state.quota.extend(quota_losses);
1344
1345        let mut records = Vec::with_capacity(results.len());
1346        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1347            let agent_id = seat.agent.clone();
1348            self.state.seats.insert(seat.key.clone(), seat);
1349            match res {
1350                Ok((proposal, out)) => {
1351                    self.state
1352                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1353                    records.push(advise::AdvisorRecord::proposed(
1354                        i + 1,
1355                        agent_id,
1356                        proposal,
1357                        out.duration_ms,
1358                    ));
1359                }
1360                Err(e) => {
1361                    self.state.event(
1362                        "advise",
1363                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1364                    );
1365                    records.push(advise::AdvisorRecord::failed(
1366                        i + 1,
1367                        agent_id,
1368                        e.to_string(),
1369                    ));
1370                }
1371            }
1372        }
1373
1374        let mut advice = advise::Advice {
1375            records,
1376            synthesis: None,
1377        };
1378        if advice.proposals().is_empty() {
1379            self.state.event(
1380                "advise",
1381                "no advisor produced a usable proposal; continuing without a \
1382                 synthesis brief"
1383                    .to_owned(),
1384            );
1385        } else {
1386            match self
1387                .synthesize_brief(
1388                    &advice,
1389                    &instruction,
1390                    &language,
1391                    &worktrees[0],
1392                    &artifacts,
1393                    &run_id,
1394                    &prompts,
1395                    cache.as_deref(),
1396                )
1397                .await
1398            {
1399                Ok(Some(text)) => {
1400                    self.state.event(
1401                        "advise",
1402                        "synthesized a design brief for the implementer".to_owned(),
1403                    );
1404                    advice.synthesis = Some(text);
1405                }
1406                Ok(None) => {
1407                    self.state.event(
1408                        "advise",
1409                        "the synthesis seat produced nothing usable; continuing \
1410                         without a design brief"
1411                            .to_owned(),
1412                    );
1413                }
1414                Err(e) => {
1415                    self.state.event(
1416                        "advise",
1417                        format!("could not synthesize a design brief: {e:#}"),
1418                    );
1419                }
1420            }
1421        }
1422        advise::apply_reflection(&mut advice);
1423
1424        self.state.advice = Some(advice);
1425        self.state.advise_attempted = true;
1426        self.state.save()?;
1427        Ok(())
1428    }
1429
1430    /// The synthesis seat: reads every advisor's proposal and blends them
1431    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1432    /// out of [`Runner::advise`] only for readability — it is not called
1433    /// anywhere else.
1434    ///
1435    /// Picked the same way [`crate::talk`]'s standing conversation and
1436    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1437    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1438    /// order (a claude seat, else the first runnable agent in roster order)
1439    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1440    /// in [`crate::config`] for why a dedicated field exists here at all.
1441    #[allow(clippy::too_many_arguments)]
1442    async fn synthesize_brief(
1443        &mut self,
1444        advice: &advise::Advice,
1445        instruction: &str,
1446        language: &str,
1447        cwd: &Path,
1448        artifacts: &Path,
1449        run_id: &str,
1450        prompts: &Prompts,
1451        cache: Option<&Path>,
1452    ) -> Result<Option<String>> {
1453        let chain = agent::pick_chain(
1454            &self.state.config.agents,
1455            self.state.config.roles.synthesizer.as_ref(),
1456            &agent::installed,
1457            "synthesizer",
1458        )?;
1459        let proposals = advice.proposals();
1460        let mut prompt = prompt::with_overlay(
1461            prompt::synthesize_brief(instruction, &proposals, language),
1462            prompts.overlay("advise"),
1463        );
1464        if cache.is_some() {
1465            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1466            // below — see `prompt::build_cache_note`'s doc for why telling a
1467            // read-only seat to build through the shared cache is exactly how
1468            // a sandbox's write refusal gets misread as a defect.
1469            prompt.push('\n');
1470            prompt.push_str(&prompt::build_cache_note("advise", false));
1471        }
1472        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1473        // Each id is tried once, in order; a quota hit, error or unusable
1474        // answer moves to the next. The seat is single-turn (`sessions:
1475        // false`) and the prompt is the whole context, so a fallback agent
1476        // needs nothing carried over.
1477        let mut last = None;
1478        for (n, spec) in chain.iter().enumerate() {
1479            if n > 0 {
1480                self.state
1481                    .event("advise", format!("synthesis falling back to {}", spec.id));
1482            }
1483            let mut seat = self.seat("advise-synthesis", &spec.id);
1484            let outcome = agent::invoke(
1485                spec,
1486                &mut seat,
1487                &Invocation {
1488                    cwd,
1489                    prompt: &prompt,
1490                    timeout,
1491                    allow_write: false,
1492                    sessions: false,
1493                    artifacts,
1494                    stem: &if n == 0 {
1495                        "advise-synthesis".to_owned()
1496                    } else {
1497                        format!("advise-synthesis-{}", spec.id)
1498                    },
1499                    run: run_id,
1500                    node: "advise",
1501                    cache_dir: None,
1502                    attachments: &[],
1503                    writable: &[],
1504                },
1505            )
1506            .await;
1507            if outcome.is_ok() {
1508                self.state.seats.insert(seat.key.clone(), seat);
1509            }
1510            let advance = agent::chain_advances(&outcome);
1511            last = Some(outcome);
1512            if !advance {
1513                break;
1514            }
1515        }
1516        // Exhausted: the last attempt's result is what a single failed seat
1517        // would have produced.
1518        let out = last.expect("a chain holds at least one agent")?;
1519        if !out.usable() {
1520            return Ok(None);
1521        }
1522        let text =
1523            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1524        Ok((!text.trim().is_empty()).then_some(text))
1525    }
1526
1527    // ----------------------------------------------------------- implement
1528
1529    async fn implement(&mut self) -> Result<()> {
1530        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1531        // agent files with `magi task add` name the run that paid for it. The
1532        // prompt overlay is cloned alongside it because the waves borrow it
1533        // while `self` is mutably borrowed by the node's own bookkeeping.
1534        let run_id = self.state.id.clone();
1535        let prompts = self.state.config.prompts.clone();
1536        let todo: Vec<usize> = self
1537            .state
1538            .candidates
1539            .iter()
1540            .enumerate()
1541            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1542            .map(|(i, _)| i)
1543            .collect();
1544        if todo.is_empty() {
1545            return self.after_implement();
1546        }
1547        self.state.status = RunStatus::Implementing;
1548
1549        let language = self.state.config.graph.language.clone();
1550        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1551        let sessions = self.state.config.graph.sessions;
1552        let artifacts = agent::artifacts_dir(&self.state.dir());
1553        // The design-deliberation stage's blended brief, when `advise` found
1554        // one — carried into every implementer's prompt the same way
1555        // regardless of which candidate it is.
1556        let brief = self
1557            .state
1558            .advice
1559            .as_ref()
1560            .and_then(|a| a.synthesis.as_deref())
1561            .map(str::to_owned);
1562        let attachments = self.state.attachments.clone();
1563
1564        let mut jobs = Vec::new();
1565        for &i in &todo {
1566            let (index, label, worktree) = {
1567                let c = &self.state.candidates[i];
1568                (c.index, c.label, c.worktree.clone())
1569            };
1570            let spec = self.roles.implementers[index].clone();
1571            let seat_key = format!("impl-{label}");
1572            let seat = self.seat(&seat_key, &spec.id);
1573            let instruction = seeded_instruction(&self.state);
1574            jobs.push(SeatJob {
1575                spec,
1576                seat,
1577                prompt: prompt::implement(
1578                    &instruction,
1579                    &worktree.to_string_lossy(),
1580                    &language,
1581                    brief.as_deref(),
1582                    &attachments,
1583                ),
1584                cwd: worktree,
1585                timeout,
1586                allow_write: true,
1587                sessions,
1588                artifacts: artifacts.clone(),
1589                stem: format!("impl-{label}"),
1590            });
1591        }
1592
1593        self.state.event(
1594            "implement",
1595            format!("{} candidates in parallel", jobs.len()),
1596        );
1597        // Kept so a seat whose CLI hung up can be asked again from the same
1598        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1599        // can update a seat's own entry once a fallback agent takes it over —
1600        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1601        // whichever agent actually answered, not the one that quota'd out.
1602        let mut sent = jobs.clone();
1603        let cache = self.state.config.cache_dir();
1604        let ctx = WaveCtx {
1605            run: &run_id,
1606            node: "implement",
1607            prompts: &prompts,
1608            cache: cache.as_deref(),
1609            round: None,
1610        };
1611        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1612        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1613            .await;
1614        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1615            .await;
1616        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1617            .await;
1618
1619        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1620            let seat_key = seat.key.clone();
1621            // A quota fallback (`resume_seat_handovers`) may have handed this
1622            // seat to a different agent than the one `prep` recorded on the
1623            // candidate; the stats tables and any later fixer-defaults-to-
1624            // winner's-author lookup must credit whoever actually answered —
1625            // unless every fallback also quota'd out, in which case nobody
1626            // actually answered and crediting the last agent tried would
1627            // erase every earlier agent's own quota loss from the stats
1628            // tables instead of just this one seat's.
1629            let agent = seat.agent.clone();
1630            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1631            self.state.seats.insert(seat.key.clone(), seat);
1632            let label = self.state.candidates[i].label;
1633            let worktree = self.state.candidates[i].worktree.clone();
1634            let base = self.state.base_commit.clone();
1635
1636            let (summary, duration, failed, verified_claim) = match out {
1637                AgentOutcome::Ok(o) => {
1638                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1639                    let failed = (!o.usable()).then(|| {
1640                        if o.timed_out {
1641                            "agent timed out".to_owned()
1642                        } else {
1643                            format!("agent exited with {:?}", o.exit_code)
1644                        }
1645                    });
1646                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1647                    (text, o.duration_ms, failed, verified_claim)
1648                }
1649                // Left un-resumed by `resume_undelivered` (a dirty tree
1650                // already rescues the work, or there was no session left to
1651                // resume into) — reported like the ordinary failure it is,
1652                // never as if `o.text` (the CLI's raw error JSON) were an
1653                // answer.
1654                AgentOutcome::Dropped(o) => {
1655                    let why = o
1656                        .dropped
1657                        .as_ref()
1658                        .map(|d| d.why.as_str())
1659                        .unwrap_or("the CLI ended the stream without delivering its answer");
1660                    (
1661                        String::new(),
1662                        o.duration_ms,
1663                        Some(format!("the CLI dropped the stream ({why})")),
1664                        None,
1665                    )
1666                }
1667                AgentOutcome::Quota(o) => {
1668                    self.state.quota.push(QuotaLoss {
1669                        seat: seat_key,
1670                        node: "implement".to_owned(),
1671                        at: Timestamp::now(),
1672                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1673                    });
1674                    (
1675                        String::new(),
1676                        o.duration_ms,
1677                        Some("rate limited (quota); produced no change".to_owned()),
1678                        None,
1679                    )
1680                }
1681                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1682            };
1683
1684            // Rescue anything the agent edited but never committed: an
1685            // uncommitted candidate would silently be an empty one.
1686            let rescued = match git::rescue_commit(
1687                &worktree,
1688                &format!("magi: candidate {label} (uncommitted work)"),
1689            )
1690            .await
1691            {
1692                Ok(r) => {
1693                    self.state.note_withheld("implement", &r.withheld);
1694                    r.committed
1695                }
1696                Err(_) => false,
1697            };
1698            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1699                .await
1700                .unwrap_or(0);
1701            let patch = git::diff(&worktree, &base, "HEAD")
1702                .await
1703                .unwrap_or_default();
1704            let stat = git::diff_stat(&worktree, &base, "HEAD")
1705                .await
1706                .unwrap_or_default();
1707            let files = git::changed_files(&worktree, &base, "HEAD")
1708                .await
1709                .map(|f| f.len())
1710                .unwrap_or(0);
1711            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1712
1713            let c = &mut self.state.candidates[i];
1714            if !exhausted_the_fallback_chain {
1715                c.agent = agent;
1716            }
1717            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1718            c.stat = stat;
1719            c.files = files;
1720            c.commits = commits;
1721            c.duration_ms = duration;
1722            c.empty = commits == 0 || patch.trim().is_empty();
1723            // An agent that failed but still produced a committed change stays
1724            // in the running: the patch is what gets judged, not the exit code.
1725            c.failed = match failed {
1726                Some(_) if c.empty => failed,
1727                _ => None,
1728            };
1729            // Only an empty candidate can be a verified no-op: a claim next
1730            // to a real patch is not what the marker is for, and `c.failed`
1731            // being `Some` here already implies `verified_claim` was never
1732            // set (see the guard above the match that produced it).
1733            c.verified_noop = if c.empty { verified_claim } else { None };
1734            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1735                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1736                (None, true, Some(_), _) => {
1737                    format!("candidate {label}: no change produced (agent-verified no-op)")
1738                }
1739                (None, true, None, _) => format!("candidate {label}: no change produced"),
1740                (None, false, _, true) => {
1741                    format!(
1742                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1743                    )
1744                }
1745                (None, false, _, false) => {
1746                    format!("candidate {label}: {files} files, {commits} commits")
1747                }
1748            };
1749            self.state.event("implement", note);
1750            self.state.save()?;
1751        }
1752
1753        self.after_implement()
1754    }
1755
1756    /// Ask again, once, for work a CLI did and then failed to hand over.
1757    ///
1758    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1759    /// status with an empty response and a usage report showing output tokens,
1760    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1761    /// seven minutes and 14,267 output tokens that arrived as an empty
1762    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1763    ///
1764    /// Two conditions, and both matter:
1765    ///
1766    /// - **Only when the tree is untouched.** Often the agent has already
1767    ///   written its files and only the closing message was lost; the rescue
1768    ///   commit below picks that up and there is nothing to ask for. Re-asking
1769    ///   then would pay for a second implementation of work already on disk.
1770    /// - **Once.** A CLI that drops one stream can drop the next, and this
1771    ///   node is the most expensive in the graph.
1772    ///
1773    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1774    /// dropped reply still carried its `conversation_id`, so the seat is asked
1775    /// to finish what it was doing rather than sent the whole task again. It
1776    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
1777    /// node's - for the same reason a re-ranked judge does: restating finished
1778    /// work is not the work.
1779    ///
1780    /// Unlike a quota this is worth retrying at all: a rate limit fails the
1781    /// same way until it resets, while an abandoned conversation is still
1782    /// there to be picked up.
1783    async fn resume_undelivered(
1784        &mut self,
1785        results: &mut [(usize, SeatState, AgentOutcome)],
1786        sent: &[SeatJob],
1787        prompts: &Prompts,
1788        run_id: &str,
1789    ) {
1790        for (wi, seat, out) in results.iter_mut() {
1791            let Some(dropped) = (match &*out {
1792                AgentOutcome::Dropped(o) => o.dropped.clone(),
1793                _ => None,
1794            }) else {
1795                continue;
1796            };
1797            let Some(job) = sent.get(*wi) else { continue };
1798            // Already on disk? Then only the closing message was lost.
1799            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1800                self.state.event(
1801                    "implement",
1802                    format!(
1803                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1804                         work is in the tree",
1805                        seat.key, dropped.output_tokens, dropped.why
1806                    ),
1807                );
1808                continue;
1809            }
1810            // The re-ask only makes sense as a resume: `resume_after_drop`
1811            // says nothing about the task, trusting the seat to still hold it.
1812            // Without a session to resume — sessions disabled, or this CLI's
1813            // drop shape happened not to carry a session id — that prompt
1814            // would open a brand-new conversation with no context at all,
1815            // which is worse than leaving this as the ordinary failure it
1816            // already is.
1817            if !has_context(&job.spec, seat, job.sessions) {
1818                self.state.event(
1819                    "implement",
1820                    format!(
1821                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1822                         is no session left to resume",
1823                        seat.key, dropped.output_tokens, dropped.why
1824                    ),
1825                );
1826                continue;
1827            }
1828            self.state.event(
1829                "implement",
1830                format!(
1831                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
1832                     conversation",
1833                    seat.key, dropped.output_tokens, dropped.why
1834                ),
1835            );
1836            let mut retry = job.clone();
1837            retry.seat = seat.clone();
1838            retry.prompt = prompt::resume_after_drop(&dropped.why);
1839            retry.timeout = retry_budget(job.timeout, true);
1840            retry.stem = format!("{}-resume", job.stem);
1841            let cache = self.state.config.cache_dir();
1842            let ctx = WaveCtx {
1843                run: run_id,
1844                node: "implement",
1845                prompts,
1846                cache: cache.as_deref(),
1847                round: None,
1848            };
1849            let (resumed_seat, resumed) =
1850                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
1851            *seat = resumed_seat;
1852            *out = resumed;
1853        }
1854    }
1855
1856    /// Fall an implement seat through to the next untried agent in the
1857    /// implementer roster when it lost to quota — or, since the handover was
1858    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
1859    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
1860    /// quota path itself being unchanged — instead of leaving the
1861    /// seat's loss final the moment one agent's account runs dry.
1862    ///
1863    /// Solo runs (`graph.candidates = 1`, `daemon::apply_solo`'s forced shape)
1864    /// are the motivating case: `Config::resolve_roles`'s `implementers`
1865    /// truncates to the single slot rotation picked, so a solo task whose one
1866    /// implementer hits quota mid-run used to have nothing else to try. This
1867    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
1868    /// unrotated roster — which is the only place the *other* candidates in
1869    /// the machine's roster still exist once `implementers` has been cut down
1870    /// to size.
1871    ///
1872    /// Walks forward from just past the seat's own original position in the
1873    /// roster, never wrapping back to the front: a later candidate slot (say
1874    /// `beta`, the roster's second entry) must fall through to the *next*
1875    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
1876    /// almost certainly a different candidate's own agent already — and once
1877    /// the roster's tail is exhausted there is nothing left to fall through
1878    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
1879    /// whole [`AgentSpec`]: a roster with the same id named twice must not
1880    /// let this retry that id forever. The loop keeps falling through until
1881    /// an attempt lands something other than `Quota` or the roster's tail
1882    /// runs out of untried ids, at which point the seat is left exactly as
1883    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
1884    /// `QuotaLoss` recorded, the candidate failed/empty.
1885    ///
1886    /// `sent` is taken mutably and updated with the fallback agent's spec:
1887    /// `resume_unconfirmed_commands`, which runs after this and also reads
1888    /// `sent`, must see whichever agent actually ended up answering the seat
1889    /// — reading the stale, original spec there would check session
1890    /// eligibility against the wrong CLI and could hand a fallback agent's
1891    /// session id to the agent that just lost the seat to quota.
1892    ///
1893    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
1894    /// — `self.seat` only reuses state when the agent id is unchanged, so
1895    /// handing it a different id already gets this for free. Reusing the old
1896    /// seat would resume a different CLI's session as if it were a
1897    /// continuation of this one.
1898    ///
1899    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
1900    /// a quota loss cuts an agent off mid-turn, so anything already in the
1901    /// tree is unfinished work, not a completed candidate a re-ask would pay
1902    /// for twice. A dirty tree is rescued into a commit first (the same
1903    /// neutral-identity rescue `implement`'s own outcome loop gives every
1904    /// candidate) so the next agent starts clean.
1905    ///
1906    /// The new agent gets the implementer's full prompt and full
1907    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
1908    /// it has no session and no context, and is implementing the task from
1909    /// nothing, unlike a resumed drop which is only restating work already
1910    /// done.
1911    ///
1912    /// Every intermediate `Quota` this loop absorbs is folded into a plain
1913    /// `implement` event, never into `self.state.quota` — that is what
1914    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
1915    /// go unspent, and a seat that ultimately recovered on its second or
1916    /// third agent is not the stalled panel that check exists to catch. Only
1917    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
1918    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
1919    /// outcome loop already has — this helper never pushes to it itself.
1920    async fn resume_seat_handovers(
1921        &mut self,
1922        results: &mut [(usize, SeatState, AgentOutcome)],
1923        sent: &mut [SeatJob],
1924        prompts: &Prompts,
1925        run_id: &str,
1926    ) {
1927        let instruction = seeded_instruction(&self.state);
1928        let language = self.state.config.graph.language.clone();
1929        let brief = self
1930            .state
1931            .advice
1932            .as_ref()
1933            .and_then(|a| a.synthesis.as_deref())
1934            .map(str::to_owned);
1935        let attachments = self.state.attachments.clone();
1936        for (wi, seat, out) in results.iter_mut() {
1937            let Some(job) = sent.get_mut(*wi) else {
1938                continue;
1939            };
1940            // Where the seat's own original agent sits in the roster — the
1941            // fallback walk starts just past here, never at the front, so a
1942            // later candidate slot's quota loss does not fall back onto an
1943            // earlier slot's own agent.
1944            let start = self
1945                .roles
1946                .implementer_roster
1947                .iter()
1948                .position(|s| s.id == job.spec.id)
1949                .unwrap_or(0);
1950            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
1951            let mut fallback_attempt = 0usize;
1952            let mut prev: Option<FailClass> = None;
1953            while let Some(cur) = FailClass::of(&*out) {
1954                if !should_hand_over(prev.as_ref(), &cur) {
1955                    break;
1956                }
1957                let Some(next) =
1958                    next_untried_in_roster(&self.roles.implementer_roster, start, &tried).cloned()
1959                else {
1960                    break;
1961                };
1962                tried.insert(next.id.clone());
1963                fallback_attempt += 1;
1964
1965                if let Ok(r) = git::rescue_commit(
1966                    &job.cwd,
1967                    &format!(
1968                        "magi: candidate {} (uncommitted work before {} fallback)",
1969                        seat.key,
1970                        if cur == FailClass::Quota {
1971                            "quota"
1972                        } else {
1973                            "handover"
1974                        }
1975                    ),
1976                )
1977                .await
1978                {
1979                    self.state.note_withheld("implement", &r.withheld);
1980                }
1981
1982                record_handover(
1983                    &mut self.state,
1984                    "implement",
1985                    &seat.key,
1986                    &seat.agent,
1987                    &next.id,
1988                    &cur,
1989                    &fail_reason(&*out),
1990                );
1991                prev = Some(cur.clone());
1992
1993                let new_seat = handover_seat(&seat.key, &next.id, self.state.seed);
1994                self.state.seats.insert(seat.key.clone(), new_seat.clone());
1995                // Kept in sync on `sent` itself, not just the local retry: a
1996                // later helper (`resume_unconfirmed_commands`) reads `sent`
1997                // after this one returns and must see whichever agent is now
1998                // occupying the seat, not the one that just quota'd out —
1999                // otherwise it would judge session/continuation eligibility
2000                // by the wrong CLI and could resend a fallback's session id
2001                // to the agent that lost it the seat in the first place.
2002                job.spec = next.clone();
2003                let mut retry = job.clone();
2004                retry.seat = new_seat;
2005                retry.prompt = prompt::implement(
2006                    &instruction,
2007                    &job.cwd.to_string_lossy(),
2008                    &language,
2009                    brief.as_deref(),
2010                    &attachments,
2011                );
2012                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2013                let cache = self.state.config.cache_dir();
2014                let ctx = WaveCtx {
2015                    run: run_id,
2016                    node: "implement",
2017                    prompts,
2018                    cache: cache.as_deref(),
2019                    round: None,
2020                };
2021                let (fallback_seat, fallback_out) = run_one(
2022                    retry,
2023                    Arc::clone(&self.sem),
2024                    &ctx,
2025                    &mut self.state,
2026                    fallback_attempt,
2027                )
2028                .await;
2029                *seat = fallback_seat;
2030                *out = fallback_out;
2031            }
2032        }
2033    }
2034
2035    /// Ask an implement seat's own CLI to confirm what it started, once, when
2036    /// its reply reported a command whose completion status it never
2037    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2038    /// that does and does not mean.
2039    ///
2040    /// The completion contract this task asks for, extended to `implement`
2041    /// with the same signal `continue_fix_report` reads for the fixer,
2042    /// rather than a keyword search over the reply or a hard requirement on
2043    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2044    /// a candidate's CLI turn ended cleanly while a test run it had started
2045    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2046    /// named in it at all is untouched by this: `commands` is empty, so
2047    /// there is nothing to be unconfirmed.
2048    ///
2049    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2050    /// this is not about recovering edits that might already be on disk, it
2051    /// is about a result the seat itself never vouched for, which resuming
2052    /// asks for regardless of what the tree already holds. Bounded to one
2053    /// attempt for the same reason `resume_undelivered` is — this is the
2054    /// most expensive node in the graph — and a seat that still cannot
2055    /// confirm on that attempt is left as whatever its (possibly still
2056    /// unconfirmed) reply says; this does not invent a new "failed" reason
2057    /// for a candidate that otherwise produced a real, committed change.
2058    async fn resume_unconfirmed_commands(
2059        &mut self,
2060        results: &mut [(usize, SeatState, AgentOutcome)],
2061        sent: &[SeatJob],
2062        prompts: &Prompts,
2063        run_id: &str,
2064    ) {
2065        for (wi, seat, out) in results.iter_mut() {
2066            let AgentOutcome::Ok(o) = &*out else {
2067                continue;
2068            };
2069            if !has_unconfirmed_command(&o.commands) {
2070                continue;
2071            }
2072            let Some(job) = sent.get(*wi) else { continue };
2073            if !has_context(&job.spec, seat, job.sessions) {
2074                self.state.event(
2075                    "implement",
2076                    format!(
2077                        "{}: the reply named a command whose own CLI never confirmed the exit \
2078                         status of, but there is no session left to resume",
2079                        seat.key
2080                    ),
2081                );
2082                continue;
2083            }
2084            self.state.event(
2085                "implement",
2086                format!(
2087                    "{}: the reply named a command whose own CLI never confirmed the exit \
2088                     status of; resuming the conversation",
2089                    seat.key
2090                ),
2091            );
2092            let mut retry = job.clone();
2093            retry.seat = seat.clone();
2094            retry.prompt = prompt::resume_incomplete(
2095                "a command in your last reply had no confirmed exit status",
2096            );
2097            retry.timeout = retry_budget(job.timeout, true);
2098            retry.stem = format!("{}-confirm", job.stem);
2099            let cache = self.state.config.cache_dir();
2100            let ctx = WaveCtx {
2101                run: run_id,
2102                node: "implement",
2103                prompts,
2104                cache: cache.as_deref(),
2105                round: None,
2106            };
2107            let (resumed_seat, resumed) =
2108                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2109            *seat = resumed_seat;
2110            *out = resumed;
2111        }
2112    }
2113
2114    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2115    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2116    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2117    /// that motivated this.
2118    ///
2119    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2120    /// own nudge loop already covers for judge/review/vote seats, and not a
2121    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2122    /// implement seats: here the CLI turn genuinely finished while the node's
2123    /// own work — the fixer's account of what it did — had not. Gated purely
2124    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2125    /// reply, never on any wording in it, so a fixer whose valid, first-try
2126    /// `FixReport` happens to mention having waited on a background test is
2127    /// never resumed — the `Ok(report)` branch at the call site returns
2128    /// before this is ever invoked.
2129    ///
2130    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2131    /// attempt ([`retry_budget`]), nothing attempted once the session is
2132    /// gone, and a quota hit ends the loop immediately rather than retrying a
2133    /// rate limit that fails the same way again.
2134    async fn continue_fix_report(
2135        &mut self,
2136        mut seat: SeatState,
2137        parse_err: String,
2138        job: &SeatJob,
2139        prompts: &Prompts,
2140        run_id: &str,
2141        round: usize,
2142    ) -> (
2143        SeatState,
2144        Option<FixReport>,
2145        Option<String>,
2146        ContinuationRecord,
2147    ) {
2148        let mut last_err = parse_err;
2149        let mut cumulative_wait_ms = 0u64;
2150        let mut attempts = 0usize;
2151        loop {
2152            if !has_context(&job.spec, &seat, job.sessions) {
2153                self.state.event(
2154                    "fix",
2155                    format!(
2156                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2157                         session left to resume into"
2158                    ),
2159                );
2160                let outcome = if attempts == 0 {
2161                    ContinuationOutcome::NoSession
2162                } else {
2163                    ContinuationOutcome::Exhausted
2164                };
2165                return (
2166                    seat,
2167                    None,
2168                    Some(format!("unparsable fix report: {last_err}")),
2169                    ContinuationRecord {
2170                        attempts,
2171                        cumulative_wait_ms,
2172                        outcome,
2173                    },
2174                );
2175            }
2176            if attempts >= MAX_FIX_CONTINUATIONS {
2177                self.state.event(
2178                    "fix",
2179                    format!(
2180                        "round {round}: fixer's reply still had no adoption report after \
2181                         {attempts} continuation(s) ({last_err}); giving up"
2182                    ),
2183                );
2184                return (
2185                    seat,
2186                    None,
2187                    Some(format!(
2188                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2189                    )),
2190                    ContinuationRecord {
2191                        attempts,
2192                        cumulative_wait_ms,
2193                        outcome: ContinuationOutcome::Exhausted,
2194                    },
2195                );
2196            }
2197            attempts += 1;
2198            self.state.event(
2199                "fix",
2200                format!(
2201                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2202                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2203                ),
2204            );
2205            let mut retry = job.clone();
2206            retry.seat = seat.clone();
2207            retry.prompt = prompt::resume_incomplete(&last_err);
2208            retry.timeout = retry_budget(job.timeout, true);
2209            retry.stem = format!("{}-continue{attempts}", job.stem);
2210            let cache = self.state.config.cache_dir();
2211            let ctx = WaveCtx {
2212                run: run_id,
2213                node: "fix",
2214                prompts,
2215                cache: cache.as_deref(),
2216                round: Some(round),
2217            };
2218            let (resumed_seat, resumed_out) = run_one(
2219                retry,
2220                Arc::clone(&self.sem),
2221                &ctx,
2222                &mut self.state,
2223                attempts,
2224            )
2225            .await;
2226            seat = resumed_seat;
2227            match resumed_out {
2228                AgentOutcome::Ok(o) => {
2229                    cumulative_wait_ms += o.duration_ms;
2230                    match verdict::extract_json::<FixReport>(&o.text) {
2231                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2232                            self.state.event(
2233                                "fix",
2234                                format!(
2235                                    "round {round}: fixer's adoption report recovered after \
2236                                     {attempts} continuation(s)"
2237                                ),
2238                            );
2239                            return (
2240                                seat,
2241                                Some(report),
2242                                None,
2243                                ContinuationRecord {
2244                                    attempts,
2245                                    cumulative_wait_ms,
2246                                    outcome: ContinuationOutcome::Resumed,
2247                                },
2248                            );
2249                        }
2250                        // The report parsed, but this same reply's own
2251                        // CommandEvidence — the identical record `state.jobs`
2252                        // renders — names a command whose CLI never
2253                        // confirmed an exit status. Read together, that is
2254                        // not a resolved answer: keep nudging rather than
2255                        // accept a report standing next to a command the
2256                        // seat's own CLI cannot vouch for.
2257                        Ok(_) => {
2258                            last_err = "the reply parsed, but it reported a command whose own CLI \
2259                                 never confirmed an exit status"
2260                                .to_owned();
2261                        }
2262                        Err(e) => last_err = e.to_string(),
2263                    }
2264                }
2265                AgentOutcome::Quota(o) => {
2266                    cumulative_wait_ms += o.duration_ms;
2267                    self.state.quota.push(QuotaLoss {
2268                        seat: seat.key.clone(),
2269                        node: "fix".to_owned(),
2270                        at: Timestamp::now(),
2271                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2272                    });
2273                    self.state.event(
2274                        "fix",
2275                        format!(
2276                            "round {round}: continuation rate limited (quota); not retrying now"
2277                        ),
2278                    );
2279                    return (
2280                        seat,
2281                        None,
2282                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2283                        ContinuationRecord {
2284                            attempts,
2285                            cumulative_wait_ms,
2286                            outcome: ContinuationOutcome::QuotaLost,
2287                        },
2288                    );
2289                }
2290                AgentOutcome::Dropped(o) => {
2291                    cumulative_wait_ms += o.duration_ms;
2292                    let why = o
2293                        .dropped
2294                        .as_ref()
2295                        .map(|d| d.why.as_str())
2296                        .unwrap_or("the CLI ended the stream without delivering its answer");
2297                    last_err = format!("the CLI dropped the stream ({why})");
2298                }
2299                AgentOutcome::Failed(e) => last_err = e,
2300            }
2301        }
2302    }
2303
2304    fn after_implement(&mut self) -> Result<()> {
2305        // Scan every candidate patch once the set is complete.
2306        if self.state.leaks.is_empty() {
2307            let cfg = self.state.config.blind.clone();
2308            let mut leaks = Vec::new();
2309            for c in &self.state.candidates {
2310                let Some(patch) =
2311                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2312                else {
2313                    continue;
2314                };
2315                leaks.extend(blind::scan(
2316                    &format!("candidate {} patch", c.label),
2317                    &patch,
2318                    &cfg.vendor_tokens,
2319                ));
2320            }
2321            if !leaks.is_empty() {
2322                let summary = leaks
2323                    .iter()
2324                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2325                    .collect::<Vec<_>>()
2326                    .join(", ");
2327                match cfg.on_leak {
2328                    LeakPolicy::Fail => {
2329                        self.state.status = RunStatus::Failed;
2330                        self.state
2331                            .event("blind", format!("vendor text in a patch: {summary}"));
2332                        self.state.leaks = leaks;
2333                        self.state.save()?;
2334                        self.settle_questions();
2335                        bail!(
2336                            "blind.on_leak = \"fail\" and vendor text reached a \
2337                             judged patch: {summary}"
2338                        );
2339                    }
2340                    LeakPolicy::Redact => self.state.event(
2341                        "blind",
2342                        format!("redacting vendor text for judging: {summary}"),
2343                    ),
2344                    LeakPolicy::Warn => self.state.event(
2345                        "blind",
2346                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2347                    ),
2348                }
2349                self.state.leaks = leaks;
2350            }
2351        }
2352
2353        if self.state.viable().is_empty() {
2354            if self.state.all_candidates_verified_noop() {
2355                // Every candidate agreed, with evidence the adoption guard
2356                // accepted, that nothing belongs in this worktree. That is
2357                // not the same fact as a candidate that simply failed to
2358                // write anything, and settling it as an ordinary `Failed`
2359                // (see `SCHEMA`'s doc for schema 10) is what let two of
2360                // task 391f's attempts burn a retry each re-discovering the
2361                // same already-landed fix. Terminal either way, so `judge`
2362                // must never run over an empty candidate set — unlike the
2363                // `Failed` branch below this returns `Ok`, not an error:
2364                // nothing here failed.
2365                self.state.status = RunStatus::VerifiedNoop;
2366                self.state.save()?;
2367                self.settle_questions();
2368                return Ok(());
2369            }
2370            self.state.status = RunStatus::Failed;
2371            self.state.save()?;
2372            self.settle_questions();
2373            bail!("no candidate produced a change; nothing to judge");
2374        }
2375        self.state.status = RunStatus::Judging;
2376        self.state.save()?;
2377        Ok(())
2378    }
2379
2380    // --------------------------------------------------------------- judge
2381
2382    async fn judge(&mut self) -> Result<()> {
2383        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2384        // agent files with `magi task add` name the run that paid for it. The
2385        // prompt overlay is cloned alongside it because the waves borrow it
2386        // while `self` is mutably borrowed by the node's own bookkeeping.
2387        let run_id = self.state.id.clone();
2388        let prompts = self.state.config.prompts.clone();
2389        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2390            return Ok(());
2391        }
2392        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2393        if viable.len() == 1 {
2394            // Recorded so this is a one-time event: `judgements` stays empty
2395            // either way, which without this flag is indistinguishable from
2396            // "not yet judged" on the next reentry — and status is left
2397            // untouched, so a later node's conclusion (e.g. `Blocked` after
2398            // the review budget ran out) survives a resume instead of being
2399            // clobbered back to `Judging` by this node running again.
2400            self.state.judge_skipped = true;
2401            self.state.event(
2402                "judge",
2403                format!(
2404                    "only candidate {} produced a change; judging skipped",
2405                    viable[0].label
2406                ),
2407            );
2408            self.state.save()?;
2409            return Ok(());
2410        }
2411        self.state.status = RunStatus::Judging;
2412
2413        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2414        let language = self.state.config.graph.language.clone();
2415        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2416        let sessions = self.state.config.graph.sessions;
2417        let artifacts = agent::artifacts_dir(&self.state.dir());
2418        let root = self.state.worktree_root();
2419        let base_short = short(&self.state.base_commit);
2420
2421        let mut jobs = Vec::new();
2422        let mut orders = Vec::new();
2423        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2424            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2425            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2426            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2427            let seat_key = format!("judge-{}", j + 1);
2428            let seat = self.seat(&seat_key, &spec.id);
2429            jobs.push(SeatJob {
2430                prompt: prompt::judge(
2431                    &self.state.instruction,
2432                    &views,
2433                    self.roles.judges.len(),
2434                    &base_short,
2435                    &language,
2436                ),
2437                spec,
2438                seat,
2439                cwd: root.join(format!("judge-{}", j + 1)),
2440                timeout,
2441                allow_write: false,
2442                sessions,
2443                artifacts: artifacts.clone(),
2444                stem: format!("judge-{}", j + 1),
2445            });
2446        }
2447
2448        self.state.event(
2449            "judge",
2450            format!(
2451                "{} judges ranking {} candidates blind",
2452                jobs.len(),
2453                viable.len()
2454            ),
2455        );
2456        let labels_for_check = labels.clone();
2457        let mut quota_losses = Vec::new();
2458        let cache = self.state.config.cache_dir();
2459        let ctx = WaveCtx {
2460            run: &run_id,
2461            node: "judge",
2462            prompts: &prompts,
2463            cache: cache.as_deref(),
2464            round: None,
2465        };
2466        let results = ask_json_wave::<Ranking>(
2467            jobs,
2468            Arc::clone(&self.sem),
2469            self.state.config.graph.retries,
2470            &self.roles.judge_roster,
2471            &ctx,
2472            &mut quota_losses,
2473            &mut self.state,
2474            &move |r: &Ranking| r.validate(&labels_for_check),
2475        )
2476        .await;
2477        self.state.quota.extend(quota_losses);
2478
2479        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2480            let agent_id = seat.agent.clone();
2481            self.state.seats.insert(seat.key.clone(), seat);
2482            let mut record = Judgement {
2483                judge: j + 1,
2484                seat: format!("judge-{}", j + 1),
2485                agent: agent_id,
2486                ranking: Vec::new(),
2487                reasons: BTreeMap::new(),
2488                confidence: None,
2489                order: orders[j].clone(),
2490                failed: None,
2491                duration_ms: 0,
2492            };
2493            match res {
2494                Ok((ranking, out)) => {
2495                    record.ranking = ranking.normalized();
2496                    record.reasons = ranking.reasons;
2497                    record.confidence = ranking.confidence;
2498                    record.duration_ms = out.duration_ms;
2499                    self.state.event(
2500                        "judge",
2501                        format!(
2502                            "judge {} ranked {}",
2503                            j + 1,
2504                            record.ranking.iter().collect::<String>()
2505                        ),
2506                    );
2507                }
2508                Err(e) => {
2509                    record.failed = Some(e.to_string());
2510                    self.state
2511                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2512                }
2513            }
2514            self.state.judgements.push(record);
2515            self.state.save()?;
2516        }
2517        Ok(())
2518    }
2519
2520    // ---------------------------------------------------------- deliberate
2521
2522    async fn deliberate(&mut self) -> Result<()> {
2523        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2524        // agent files with `magi task add` name the run that paid for it. The
2525        // prompt overlay is cloned alongside it because the waves borrow it
2526        // while `self` is mutably borrowed by the node's own bookkeeping.
2527        let run_id = self.state.id.clone();
2528        let prompts = self.state.config.prompts.clone();
2529        if !self.state.deliberation.is_empty() {
2530            return Ok(());
2531        }
2532        let tops: Vec<char> = self
2533            .state
2534            .judgements
2535            .iter()
2536            .filter_map(|j| j.ranking.first().copied())
2537            .collect();
2538        let rounds = self.state.config.graph.deliberate_rounds;
2539        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2540            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2541                self.state.event(
2542                    "deliberate",
2543                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2544                );
2545            }
2546            self.state.status = RunStatus::Voting;
2547            self.state.save()?;
2548            return Ok(());
2549        }
2550
2551        self.state.status = RunStatus::Deliberating;
2552        self.state.event(
2553            "deliberate",
2554            format!(
2555                "split: first choices were {} — opening {rounds} round(s)",
2556                tops.iter().collect::<String>()
2557            ),
2558        );
2559
2560        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2561        let language = self.state.config.graph.language.clone();
2562        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2563        let sessions = self.state.config.graph.sessions;
2564        let artifacts = agent::artifacts_dir(&self.state.dir());
2565        let root = self.state.worktree_root();
2566        let base_short = short(&self.state.base_commit);
2567
2568        // Judges argue in sequence so that a turn can answer the one before it;
2569        // that is the difference between deliberation and three parallel
2570        // monologues.
2571        for round in 1..=rounds {
2572            let mut turns: Vec<DeliberationTurn> = Vec::new();
2573            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2574                if self.state.judgements[j].failed.is_some() {
2575                    continue;
2576                }
2577                let seat_key = format!("judge-{}", j + 1);
2578                let spec = self.occupant(&seat_key, spec);
2579                let mut seat = self.seat(&seat_key, &spec.id);
2580                let transcript = self.transcript(&turns, j);
2581                let context = if has_context(&spec, &seat, sessions) {
2582                    None
2583                } else {
2584                    Some(self.candidate_block(&viable, &base_short))
2585                };
2586                let text = prompt::deliberate(
2587                    &self.state.instruction,
2588                    context.as_deref(),
2589                    &transcript,
2590                    round,
2591                    rounds,
2592                    &language,
2593                );
2594                let job = SeatJob {
2595                    spec,
2596                    seat: seat.clone(),
2597                    prompt: text,
2598                    cwd: root.join(format!("judge-{}", j + 1)),
2599                    timeout,
2600                    allow_write: false,
2601                    sessions,
2602                    artifacts: artifacts.clone(),
2603                    stem: format!("delib-{round}-judge-{}", j + 1),
2604                };
2605                let cache = self.state.config.cache_dir();
2606                let ctx = WaveCtx {
2607                    run: &run_id,
2608                    node: "deliberate",
2609                    prompts: &prompts,
2610                    cache: cache.as_deref(),
2611                    round: None,
2612                };
2613                let (updated, out) =
2614                    run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
2615                seat = updated;
2616                let agent_id = seat.agent.clone();
2617                let seat_key = seat.key.clone();
2618                self.state.seats.insert(seat.key.clone(), seat);
2619                let body = match out {
2620                    AgentOutcome::Ok(o) => verdict::section(&o.text, "position").unwrap_or(o.text),
2621                    // Never read the CLI's raw error JSON as this judge's
2622                    // position — skip the seat instead, the same as any other
2623                    // failed turn.
2624                    AgentOutcome::Dropped(o) => {
2625                        let why =
2626                            o.dropped.as_ref().map(|d| d.why.as_str()).unwrap_or(
2627                                "the CLI ended the stream without delivering its answer",
2628                            );
2629                        self.state.event(
2630                            "deliberate",
2631                            format!(
2632                                "judge {} skipped: the CLI dropped the stream ({why})",
2633                                j + 1
2634                            ),
2635                        );
2636                        continue;
2637                    }
2638                    AgentOutcome::Quota(o) => {
2639                        self.state.quota.push(QuotaLoss {
2640                            seat: seat_key,
2641                            node: "deliberate".to_owned(),
2642                            at: Timestamp::now(),
2643                            reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2644                        });
2645                        self.state.event(
2646                            "deliberate",
2647                            format!("judge {} skipped: rate limited (quota)", j + 1),
2648                        );
2649                        continue;
2650                    }
2651                    AgentOutcome::Failed(e) => {
2652                        self.state
2653                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2654                        continue;
2655                    }
2656                };
2657                let tentative = verdict::extract_json::<Position>(&body)
2658                    .ok()
2659                    .and_then(|p| p.tentative)
2660                    .and_then(|s| s.trim().chars().next())
2661                    .map(|c| c.to_ascii_uppercase());
2662                self.state.event(
2663                    "deliberate",
2664                    format!(
2665                        "round {round}: judge {} now favours {}",
2666                        j + 1,
2667                        tentative.map_or("—".to_owned(), |c| c.to_string())
2668                    ),
2669                );
2670                turns.push(DeliberationTurn {
2671                    judge: j + 1,
2672                    agent: agent_id,
2673                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2674                    tentative,
2675                });
2676            }
2677            self.state
2678                .deliberation
2679                .push(DeliberationRound { round, turns });
2680            self.state.save()?;
2681        }
2682
2683        self.state.status = RunStatus::Voting;
2684        self.state.save()?;
2685        Ok(())
2686    }
2687
2688    // ---------------------------------------------------------------- vote
2689
2690    async fn vote(&mut self) -> Result<()> {
2691        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2692        // agent files with `magi task add` name the run that paid for it. The
2693        // prompt overlay is cloned alongside it because the waves borrow it
2694        // while `self` is mutably borrowed by the node's own bookkeeping.
2695        let run_id = self.state.id.clone();
2696        let prompts = self.state.config.prompts.clone();
2697        if !self.state.votes.is_empty() {
2698            return Ok(());
2699        }
2700        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2701        if viable.len() == 1 {
2702            return Ok(());
2703        }
2704        self.state.status = RunStatus::Voting;
2705
2706        let language = self.state.config.graph.language.clone();
2707        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2708        let sessions = self.state.config.graph.sessions;
2709        let artifacts = agent::artifacts_dir(&self.state.dir());
2710        let root = self.state.worktree_root();
2711        let base_short = short(&self.state.base_commit);
2712        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2713
2714        let mut jobs = Vec::new();
2715        let mut seats_at = Vec::new();
2716        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2717            if self
2718                .state
2719                .judgements
2720                .get(j)
2721                .is_some_and(|r| r.failed.is_some())
2722            {
2723                continue;
2724            }
2725            let seat_key = format!("judge-{}", j + 1);
2726            let spec = self.occupant(&seat_key, spec);
2727            let seat = self.seat(&seat_key, &spec.id);
2728            let mut text = prompt::final_vote(&viable, &language);
2729            if !has_context(&spec, &seat, sessions) {
2730                text = format!(
2731                    "{}\n\n# Candidates\n\n{}",
2732                    text,
2733                    self.candidate_block(&candidates, &base_short)
2734                );
2735            }
2736            jobs.push(SeatJob {
2737                spec,
2738                seat,
2739                prompt: text,
2740                cwd: root.join(format!("judge-{}", j + 1)),
2741                timeout,
2742                allow_write: false,
2743                sessions,
2744                artifacts: artifacts.clone(),
2745                stem: format!("vote-judge-{}", j + 1),
2746            });
2747            seats_at.push(j);
2748        }
2749
2750        self.state.event(
2751            "vote",
2752            format!(
2753                "collecting {} final votes one by one, privately",
2754                jobs.len()
2755            ),
2756        );
2757        let allowed = viable.clone();
2758        let mut quota_losses = Vec::new();
2759        let cache = self.state.config.cache_dir();
2760        let ctx = WaveCtx {
2761            run: &run_id,
2762            node: "vote",
2763            prompts: &prompts,
2764            cache: cache.as_deref(),
2765            round: None,
2766        };
2767        let results = ask_json_wave::<FinalVote>(
2768            jobs,
2769            Arc::clone(&self.sem),
2770            self.state.config.graph.retries,
2771            &[],
2772            &ctx,
2773            &mut quota_losses,
2774            &mut self.state,
2775            &move |v: &FinalVote| match v.label() {
2776                Some(c) if allowed.contains(&c) => Ok(()),
2777                other => bail!("vote {other:?} is not one of {allowed:?}"),
2778            },
2779        )
2780        .await;
2781        self.state.quota.extend(quota_losses);
2782
2783        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2784            let agent_id = seat.agent.clone();
2785            self.state.seats.insert(seat.key.clone(), seat);
2786            let initial = self
2787                .state
2788                .judgements
2789                .get(j)
2790                .and_then(|r| r.ranking.first().copied());
2791            let mut record = VoteRecord {
2792                judge: j + 1,
2793                agent: agent_id,
2794                vote: None,
2795                reason: String::new(),
2796                changed: false,
2797            };
2798            match res {
2799                Ok((v, _)) => {
2800                    record.vote = v.label();
2801                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2802                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2803                    self.state.event(
2804                        "vote",
2805                        format!(
2806                            "judge {} voted {}{}",
2807                            j + 1,
2808                            record.vote.unwrap_or('?'),
2809                            if record.changed { " (changed)" } else { "" }
2810                        ),
2811                    );
2812                }
2813                Err(e) => {
2814                    self.state
2815                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2816                }
2817            }
2818            self.state.votes.push(record);
2819            self.state.save()?;
2820        }
2821        Ok(())
2822    }
2823
2824    // --------------------------------------------------------------- tally
2825
2826    fn tally(&mut self) -> Result<()> {
2827        if self.state.tally.is_some() {
2828            return Ok(());
2829        }
2830        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2831        let tops: Vec<char> = self
2832            .state
2833            .judgements
2834            .iter()
2835            .filter_map(|j| j.ranking.first().copied())
2836            .collect();
2837        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2838
2839        // A judge whose private vote failed still counted once, in the initial
2840        // ranking; using it beats discarding a whole seat.
2841        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2842        let mut cast: Vec<char> = Vec::new();
2843        for (i, j) in self.state.judgements.iter().enumerate() {
2844            let vote = self
2845                .state
2846                .votes
2847                .iter()
2848                .find(|v| v.judge == i + 1)
2849                .and_then(|v| v.vote)
2850                .or_else(|| j.ranking.first().copied());
2851            if let Some(v) = vote {
2852                *first_choice.entry(v).or_insert(0) += 1;
2853                cast.push(v);
2854            }
2855        }
2856
2857        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2858        for j in &self.state.judgements {
2859            let n = j.ranking.len();
2860            for (pos, label) in j.ranking.iter().enumerate() {
2861                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2862            }
2863        }
2864
2865        let best = first_choice.values().copied().max().unwrap_or(0);
2866        let mut leaders: Vec<char> = first_choice
2867            .iter()
2868            .filter(|(_, v)| **v == best)
2869            .map(|(k, _)| *k)
2870            .collect();
2871        let mut tie_break = None;
2872        if leaders.len() > 1 {
2873            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2874            let borda_leaders: Vec<char> = leaders
2875                .iter()
2876                .copied()
2877                .filter(|l| borda[l] == top_borda)
2878                .collect();
2879            tie_break = Some(if borda_leaders.len() == 1 {
2880                format!(
2881                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
2882                    leaders.len()
2883                )
2884            } else {
2885                format!(
2886                    "{} way tie on both first-choice votes and Borda points, broken by label order",
2887                    leaders.len()
2888                )
2889            });
2890            leaders = borda_leaders;
2891            leaders.sort_unstable();
2892        }
2893        let winner = *leaders
2894            .first()
2895            .or(viable.first())
2896            .context("no candidate to declare a winner from")?;
2897
2898        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
2899        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
2900        let deliberated = !self.state.deliberation.is_empty();
2901
2902        // Whose verdict is this? A rate-limited seat is absent even if it
2903        // ranked before the limit hit, so presence is measured against the
2904        // recorded losses, not just "did a ranking ever appear".
2905        let quota_seats: std::collections::BTreeSet<&str> =
2906            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
2907        let mut present = 0usize;
2908        for (i, j) in self.state.judgements.iter().enumerate() {
2909            if quota_seats.contains(j.seat.as_str()) {
2910                continue;
2911            }
2912            let ranked = !j.ranking.is_empty() && j.failed.is_none();
2913            let voted = self
2914                .state
2915                .votes
2916                .iter()
2917                .any(|v| v.judge == i + 1 && v.vote.is_some());
2918            if ranked || voted {
2919                present += 1;
2920            }
2921        }
2922        // Strict majority of the configured panel. A bare majority is real
2923        // signal we can act on, while a minority verdict must never stand in
2924        // for a healthy one. A one-candidate run needs no panel at all, and
2925        // `judges` stays `0` rather than the roster size a panel that never
2926        // sat would otherwise be credited with.
2927        let needs_quorum = viable.len() > 1;
2928        let judges_total = if needs_quorum {
2929            self.roles.judges.len()
2930        } else {
2931            0
2932        };
2933        let quorum = if needs_quorum {
2934            judges_total / 2 + 1
2935        } else {
2936            0
2937        };
2938        let met_quorum = !needs_quorum || present >= quorum;
2939        let uncontested = (!needs_quorum).then(|| {
2940            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
2941        });
2942
2943        self.state.event(
2944            "tally",
2945            match &uncontested {
2946                Some(reason) => format!("winner {winner} — {reason}"),
2947                None => format!(
2948                    "winner {winner} — votes {} | initial {} | {} changed | \
2949                     {present}/{judges_total} judges{}",
2950                    first_choice
2951                        .iter()
2952                        .map(|(k, v)| format!("{k}:{v}"))
2953                        .collect::<Vec<_>>()
2954                        .join(" "),
2955                    if unanimous_initial {
2956                        "unanimous"
2957                    } else {
2958                        "split"
2959                    },
2960                    changed_votes,
2961                    if met_quorum {
2962                        String::new()
2963                    } else {
2964                        format!(" — below quorum ({quorum} required)")
2965                    },
2966                ),
2967            },
2968        );
2969        if !met_quorum {
2970            self.state.event(
2971                "stall",
2972                format!(
2973                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
2974                     the run stops here, resumable"
2975                ),
2976            );
2977        }
2978        self.state.tally = Some(Tally {
2979            first_choice,
2980            borda,
2981            winner,
2982            rankings: tops.len(),
2983            unanimous_initial,
2984            deliberated,
2985            changed_votes,
2986            unanimous_final,
2987            tie_break,
2988            judges: judges_total,
2989            present,
2990            quorum,
2991            met_quorum,
2992            uncontested,
2993        });
2994        self.state.status = if met_quorum {
2995            RunStatus::Reviewing
2996        } else {
2997            RunStatus::Stalled
2998        };
2999        self.state.save()?;
3000        Ok(())
3001    }
3002
3003    // ------------------------------------------------------------- recover
3004
3005    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3006    /// resumed toward completion once the transient cause clears.
3007    ///
3008    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3009    /// it toward the quorum, which is exactly the set of seats whose absence
3010    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3011    /// not depend on which node happened to hit the limit), or an ordinary
3012    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3013    /// seat is never disturbed.
3014    ///
3015    /// A seat that now answers with a usable ranking is "recovered": its
3016    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3017    /// `tally` counts it present again), and its vote re-collected. A seat that
3018    /// still fails keeps its loss and stays absent.
3019    ///
3020    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3021    /// to review/gate/merge), `false` when it is still below quorum (the run
3022    /// stays `Stalled`, still resumable for a later retry).
3023    #[allow(clippy::too_many_lines)]
3024    async fn recover_stall(&mut self) -> Result<bool> {
3025        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3026        // agent files with `magi task add` name the run that paid for it. The
3027        // prompt overlay is cloned alongside it because the waves borrow it
3028        // while `self` is mutably borrowed by the node's own bookkeeping.
3029        let run_id = self.state.id.clone();
3030        let prompts = self.state.config.prompts.clone();
3031        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3032        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3033        // is what keeps a non-quota collapse — or a quota loss recorded at the
3034        // deliberate node — from being a permanent dead-end on `--resume`.
3035        let quota_seats: BTreeSet<&str> =
3036            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3037        let absent: Vec<String> = self
3038            .state
3039            .judgements
3040            .iter()
3041            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3042            .map(|j| j.seat.clone())
3043            .collect();
3044        if absent.is_empty() {
3045            return Ok(false);
3046        }
3047        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3048        if viable.len() <= 1 {
3049            return Ok(false);
3050        }
3051        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3052        let language = self.state.config.graph.language.clone();
3053        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3054        let sessions = self.state.config.graph.sessions;
3055        let artifacts = agent::artifacts_dir(&self.state.dir());
3056        let root = self.state.worktree_root();
3057        let base_short = short(&self.state.base_commit);
3058        let candidates: Vec<Candidate> = viable.clone();
3059
3060        // Map each absent seat key to its 0-based position in `roles.judges`.
3061        let mut positions: Vec<usize> = absent
3062            .iter()
3063            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3064            .collect();
3065        if positions.is_empty() {
3066            return Ok(false);
3067        }
3068        positions.sort_unstable();
3069        positions.dedup();
3070
3071        // Re-rank the lost seats, one blind prompt each.
3072        let mut judge_jobs = Vec::new();
3073        for &j in &positions {
3074            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3075            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3076            let seat_key = format!("judge-{}", j + 1);
3077            let spec = self.roles.judges[j].clone();
3078            let seat = self.seat(&seat_key, &spec.id);
3079            judge_jobs.push(SeatJob {
3080                spec,
3081                seat,
3082                prompt: prompt::judge(
3083                    &self.state.instruction,
3084                    &views,
3085                    self.roles.judges.len(),
3086                    &base_short,
3087                    &language,
3088                ),
3089                cwd: root.join(seat_key),
3090                timeout,
3091                allow_write: false,
3092                sessions,
3093                artifacts: artifacts.clone(),
3094                stem: format!("judge-{}-recover", j + 1),
3095            });
3096        }
3097
3098        let labels_for_check = labels.clone();
3099        let mut judge_losses = Vec::new();
3100        let retries = self.state.config.graph.retries;
3101        let cache = self.state.config.cache_dir();
3102        let ctx = WaveCtx {
3103            run: &run_id,
3104            node: "judge",
3105            prompts: &prompts,
3106            cache: cache.as_deref(),
3107            round: None,
3108        };
3109        let results = ask_json_wave::<Ranking>(
3110            judge_jobs,
3111            Arc::clone(&self.sem),
3112            retries,
3113            &[],
3114            &ctx,
3115            &mut judge_losses,
3116            &mut self.state,
3117            &move |r: &Ranking| r.validate(&labels_for_check),
3118        )
3119        .await;
3120
3121        // Refresh the judgement of every seat that ranked again.
3122        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3123        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3124            self.state.seats.insert(seat.key.clone(), seat);
3125            let record = &mut self.state.judgements[j];
3126            match res {
3127                Ok((ranking, out)) => {
3128                    record.ranking = ranking.normalized();
3129                    record.reasons = ranking.reasons;
3130                    record.confidence = ranking.confidence;
3131                    record.failed = None;
3132                    record.duration_ms = out.duration_ms;
3133                    recovered.insert(j);
3134                    self.state.event(
3135                        "recover",
3136                        format!("judge {} ranked again after the limit", j + 1),
3137                    );
3138                }
3139                Err(e) => {
3140                    self.state
3141                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3142                }
3143            }
3144        }
3145
3146        // Re-ask the votes of the seats that recovered a ranking.
3147        let mut vote_jobs = Vec::new();
3148        let mut vote_pos: Vec<usize> = Vec::new();
3149        for &j in &recovered {
3150            let seat_key = format!("judge-{}", j + 1);
3151            let spec = self.roles.judges[j].clone();
3152            let seat = self.seat(&seat_key, &spec.id);
3153            let mut text = prompt::final_vote(&labels, &language);
3154            if !has_context(&spec, &seat, sessions) {
3155                text = format!(
3156                    "{}\n\n# Candidates\n\n{}",
3157                    text,
3158                    self.candidate_block(&candidates, &base_short)
3159                );
3160            }
3161            vote_jobs.push(SeatJob {
3162                spec,
3163                seat,
3164                prompt: text,
3165                cwd: root.join(seat_key),
3166                timeout,
3167                allow_write: false,
3168                sessions,
3169                artifacts: artifacts.clone(),
3170                stem: format!("vote-judge-{}-recover", j + 1),
3171            });
3172            vote_pos.push(j);
3173        }
3174        let allowed = labels.clone();
3175        let mut vote_losses = Vec::new();
3176        let vote_retries = self.state.config.graph.retries;
3177        let vote_cache = self.state.config.cache_dir();
3178        let ctx = WaveCtx {
3179            run: &run_id,
3180            node: "vote",
3181            prompts: &prompts,
3182            cache: vote_cache.as_deref(),
3183            round: None,
3184        };
3185        let votes = ask_json_wave::<FinalVote>(
3186            vote_jobs,
3187            Arc::clone(&self.sem),
3188            vote_retries,
3189            &[],
3190            &ctx,
3191            &mut vote_losses,
3192            &mut self.state,
3193            &move |v: &FinalVote| match v.label() {
3194                Some(c) if allowed.contains(&c) => Ok(()),
3195                other => bail!("vote {other:?} is not one of {allowed:?}"),
3196            },
3197        )
3198        .await;
3199        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3200            let agent_id = seat.agent.clone();
3201            self.state.seats.insert(seat.key.clone(), seat);
3202            match res {
3203                Ok((v, _)) => {
3204                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3205                        rec.vote = v.label();
3206                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3207                    } else {
3208                        self.state.votes.push(VoteRecord {
3209                            judge: j + 1,
3210                            agent: agent_id,
3211                            vote: v.label(),
3212                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3213                            changed: false,
3214                        });
3215                    }
3216                    self.state.event(
3217                        "recover",
3218                        format!("judge {} voted again after the limit", j + 1),
3219                    );
3220                }
3221                Err(e) => {
3222                    self.state
3223                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3224                }
3225            }
3226        }
3227
3228        // A seat that ranked again is present even if its re-vote failed —
3229        // `tally` falls back to the initial ranking's first choice — so clear
3230        // its quota loss. Seats that still fail keep theirs and stay absent.
3231        let recovered_keys: BTreeSet<String> = recovered
3232            .iter()
3233            .map(|&j| format!("judge-{}", j + 1))
3234            .collect();
3235        self.state
3236            .quota
3237            .retain(|q| !recovered_keys.contains(&q.seat));
3238        // A seat that hit the limit again is a fresh loss, not the old one:
3239        // replace the stale entry so the history stays one-per-seat and the
3240        // daemon can tell this attempt's loss from a previous session's.
3241        for loss in judge_losses.into_iter().chain(vote_losses) {
3242            if recovered_keys.contains(&loss.seat) {
3243                continue;
3244            }
3245            self.state.quota.retain(|q| q.seat != loss.seat);
3246            self.state.quota.push(loss);
3247        }
3248
3249        // Recompute the verdict from the refreshed panel.
3250        self.state.tally = None;
3251        self.tally()?;
3252        Ok(self
3253            .state
3254            .tally
3255            .as_ref()
3256            .map(|t| t.met_quorum)
3257            .unwrap_or(false))
3258    }
3259
3260    // ----------------------------------------------------------------- fold
3261
3262    async fn fold_losers(&mut self) -> Result<()> {
3263        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3264            return Ok(());
3265        };
3266        let repo = self.state.repo.clone();
3267        let mut folded = Vec::new();
3268        for i in 0..self.state.candidates.len() {
3269            let c = &self.state.candidates[i];
3270            if c.label == winner || c.folded {
3271                continue;
3272            }
3273            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3274            git::worktree_remove(&repo, &wt).await.ok();
3275            git::branch_delete(&repo, &branch).await.ok();
3276            self.state.candidates[i].folded = true;
3277            folded.push(label.to_string());
3278        }
3279        // The judges are finished; their checkouts are pure cost from here.
3280        let root = self.state.worktree_root();
3281        for j in 1..=self.roles.judges.len() {
3282            let wt = root.join(format!("judge-{j}"));
3283            if wt.exists() {
3284                git::worktree_remove(&repo, &wt).await.ok();
3285            }
3286        }
3287        // The design-deliberation stage is finished by the time a tally
3288        // exists — same reasoning as the judges above.
3289        if self.state.config.graph.advise {
3290            for k in 1..=self.state.config.graph.advisors {
3291                let wt = root.join(format!("advisor-{k}"));
3292                if wt.exists() {
3293                    git::worktree_remove(&repo, &wt).await.ok();
3294                }
3295            }
3296        }
3297        if !folded.is_empty() {
3298            self.state
3299                .event("fold", format!("folded candidates {}", folded.join(", ")));
3300            self.state.save()?;
3301        }
3302        Ok(())
3303    }
3304
3305    // ------------------------------------------------------------ base sync
3306
3307    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3308    /// anything verifies it.
3309    ///
3310    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3311    /// read whatever is checked out in the winner's worktree. Left alone that
3312    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3313    /// it when the run *branched* - and a run takes long enough that the base
3314    /// has usually moved by the time it gets here. A gate that ran there
3315    /// answers "green on the commit this run started from", not "green on
3316    /// what is about to land", and the difference showed up three times in
3317    /// one day as a green run whose merge would have reverted a file another
3318    /// pull request had already landed.
3319    ///
3320    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3321    /// `land::Step::Rebase` calls, rather than a second implementation of the
3322    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3323    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3324    /// doc warns about ("two answers to a question nobody notices until a
3325    /// diff is wrong").
3326    ///
3327    /// A conflict is not the end of the road: the standing rebase is handed
3328    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3329    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3330    /// land). Once it finishes, review and the gate run as usual on the
3331    /// rebased tree, which is where a breakage the new base caused is caught
3332    /// by the ordinary gate-fix round. magi resolves nothing itself.
3333    ///
3334    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3335    /// `state.base_sync.attempts`, is how many times the base is *rebased
3336    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3337    /// *conflict* was given to a fixer. When the fixer cannot finish the
3338    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3339    /// what was tried (rounds spent, paths still conflicted) and the branch
3340    /// and worktree stay exactly as they were - untouched, for a person to
3341    /// look at - which is also what makes re-entering this function
3342    /// afterwards a no-op instead of a second attempt at the same wall. A
3343    /// push failure ends the same way.
3344    async fn sync_to_base(&mut self) -> Result<()> {
3345        if self.state.status == RunStatus::AlreadyInBase {
3346            return Ok(());
3347        }
3348        let conflicted = self
3349            .state
3350            .base_sync
3351            .as_ref()
3352            .is_some_and(|s| s.conflict.is_some());
3353        let Some(winner) = self.state.winner().cloned() else {
3354            return Ok(());
3355        };
3356
3357        let repo = self.state.repo.clone();
3358        let remote = self.state.config.merge.remote.clone();
3359        let base_branch = self.state.base_branch.clone();
3360        let tracking = format!("{remote}/{base_branch}");
3361
3362        git::fetch(&repo, &remote, &base_branch).await.ok();
3363        // No network, or the remote never had this branch: `resolve_base`
3364        // already treats that as non-fatal at branch time, and a run that got
3365        // this far must not be blocked by it here either.
3366        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3367            return Ok(());
3368        };
3369
3370        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3371        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3372        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3373
3374        // Before any rebase, and before a recorded conflict is honoured: a
3375        // branch whose change reached the base under other commit ids has
3376        // nothing to rebase and nothing to conflict with, and a run that
3377        // already stopped on that phantom conflict recovers here on resume.
3378        // `behind == 0` with head == tip is a branch the base has since taken
3379        // in whole, whether or not a conflict was ever recorded: the ancestry
3380        // proof must still run (`classify` ignores a head still on the start
3381        // commit).
3382        if (behind > 0 || conflicted || head == tip)
3383            && self
3384                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3385                .await?
3386        {
3387            return Ok(());
3388        }
3389        if conflicted {
3390            return Ok(());
3391        }
3392
3393        if behind == 0 {
3394            // A fixer-finished rebase moves the branch ref before the
3395            // winner's worktree is told (`sync_to_head` below). A run that
3396            // died in between resumes here with `behind == 0` and a tree still
3397            // holding the pre-rebase files, which review and the gate would
3398            // then read. That state is exactly: HEAD moved off the tip the
3399            // rebase started from, yet the tree is still identical to that
3400            // tip. A tree with edits of its own differs from it, so nothing
3401            // is thrown away.
3402            if let Some(from) = self
3403                .state
3404                .rebase_fixes
3405                .iter()
3406                .rev()
3407                .find_map(|r| r.from.clone())
3408                && from != head
3409                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3410                    .await
3411                    .is_ok_and(|o| o.ok())
3412            {
3413                git::sync_to_head(&winner.worktree).await?;
3414            }
3415            // An earlier attempt may have rebased the branch locally and died
3416            // before pushing it (only the fresh-rebase arm below pushes).
3417            // Publish it now, so the plain push at PR time is not refused as
3418            // a non-fast-forward. A run already holding a recorded conflict
3419            // never reaches here; that case is out of scope.
3420            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3421            if let Some(why) = &conflict {
3422                self.state.status = RunStatus::Blocked;
3423                self.state.event("land", why.clone());
3424            }
3425            self.state.base_sync = Some(BaseSync {
3426                tip,
3427                behind: 0,
3428                attempts,
3429                conflict,
3430                already_in: None,
3431            });
3432            self.state.save()?;
3433            return Ok(());
3434        }
3435
3436        if attempts >= BASE_SYNC_ROUNDS {
3437            let why = format!(
3438                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3439                 rebase(s); rebasing again would only race it",
3440                winner.branch
3441            );
3442            self.state.status = RunStatus::Blocked;
3443            self.state.base_sync = Some(BaseSync {
3444                tip,
3445                behind,
3446                attempts,
3447                conflict: Some(why.clone()),
3448                already_in: None,
3449            });
3450            self.state.event("land", why);
3451            self.state.save()?;
3452            return Ok(());
3453        }
3454
3455        self.state.event(
3456            "land",
3457            format!(
3458                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3459                winner.branch
3460            ),
3461        );
3462        self.state.save()?;
3463
3464        // The remote's copy of the branch, read now and only if the fetch
3465        // really succeeded (a stale tracking ref must never pin a lease). It is
3466        // pushed over after a rebase only when it is a commit this branch
3467        // already contains, by ancestry or by patch (an earlier rebase of ours
3468        // that never reached the remote): anything else is somebody else's work.
3469        let branch_tracking = format!("{remote}/{}", winner.branch);
3470        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3471        let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3472            git::rev_parse(&repo, &branch_tracking).await.ok()
3473        } else {
3474            None
3475        };
3476        // A remote tip this branch does not contain is somebody else's work:
3477        // rebasing would leave a local tip that can never be pushed. Stop
3478        // before touching anything and say so.
3479        if let Some(theirs) = &remote_tip
3480            && !git::is_ancestor(&repo, theirs, &head).await
3481            && !crate::reconcile::origin_missing(&repo, &head, theirs)
3482                .await
3483                .is_ok_and(|missing| missing.is_empty())
3484        {
3485            let why = format!(
3486                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3487                 them",
3488                short(theirs),
3489                winner.branch
3490            );
3491            self.state.status = RunStatus::Blocked;
3492            self.state.base_sync = Some(BaseSync {
3493                tip,
3494                behind,
3495                attempts,
3496                conflict: Some(why.clone()),
3497                already_in: None,
3498            });
3499            self.state.event("land", why);
3500            self.state.save()?;
3501            return Ok(());
3502        }
3503
3504        let scratch = self.state.dir().join("base-sync");
3505        let rebased = match crate::rebase::rebase_with_fixer(
3506            &mut self.state,
3507            &scratch,
3508            &winner.branch,
3509            &tracking,
3510        )
3511        .await
3512        {
3513            Ok(crate::rebase::Rebased::Applied) => Ok(None),
3514            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3515            Err(e) => Err(e),
3516        };
3517        let attempts = attempts + 1;
3518        match rebased {
3519            Ok(None) => {
3520                // The branch ref moved, but a worktree that already had it
3521                // checked out (the winner's) was not told; sync its index and
3522                // files before anything reads them.
3523                git::sync_to_head(&winner.worktree).await?;
3524                let mut conflict = None;
3525                if let Some(pinned) = &remote_tip {
3526                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3527                    match pushed {
3528                        Ok(o) if o.ok() => self.state.event(
3529                            "land",
3530                            format!("pushed rebased {} to {remote}", winner.branch),
3531                        ),
3532                        Ok(o) => {
3533                            conflict = Some(format!(
3534                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
3535                                winner.branch,
3536                                short(pinned),
3537                                o.stderr.chars().take(600).collect::<String>()
3538                            ));
3539                        }
3540                        Err(e) => {
3541                            conflict = Some(format!(
3542                                "rebased {} locally but could not push it: {e:#}",
3543                                winner.branch
3544                            ));
3545                        }
3546                    }
3547                }
3548                if let Some(why) = &conflict {
3549                    self.state.status = RunStatus::Blocked;
3550                    self.state.event("land", why.clone());
3551                }
3552                self.state.base_sync = Some(BaseSync {
3553                    tip: tip.clone(),
3554                    behind: 0,
3555                    attempts,
3556                    conflict,
3557                    already_in: None,
3558                });
3559                self.state
3560                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
3561            }
3562            Ok(Some(conflict)) => {
3563                let why = format!(
3564                    "{} conflicts with {tracking} and did not rebase: {}",
3565                    winner.branch,
3566                    conflict.chars().take(600).collect::<String>()
3567                );
3568                self.state.status = RunStatus::Blocked;
3569                self.state.base_sync = Some(BaseSync {
3570                    tip,
3571                    behind,
3572                    attempts,
3573                    conflict: Some(why.clone()),
3574                    already_in: None,
3575                });
3576                self.state.event("land", why);
3577            }
3578            Err(e) => {
3579                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3580                self.state.status = RunStatus::Blocked;
3581                self.state.base_sync = Some(BaseSync {
3582                    tip,
3583                    behind,
3584                    attempts,
3585                    conflict: Some(why.clone()),
3586                    already_in: None,
3587                });
3588                self.state.event("land", why);
3589            }
3590        }
3591        self.state.save()?;
3592        Ok(())
3593    }
3594
3595    /// Push a branch an earlier attempt rebased locally but never published,
3596    /// pinned to the remote tip read right after a successful fetch. Returns
3597    /// the reason when the run must stop; `None` when there was nothing to do
3598    /// (no remote copy, the same tip, or a remote copy this branch already
3599    /// contains, which the PR-time push fast-forwards) or the push succeeded.
3600    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3601        let repo = self.state.repo.clone();
3602        let remote = self.state.config.merge.remote.clone();
3603        let fetched = git::fetch(&repo, &remote, branch).await;
3604        if !matches!(&fetched, Ok(o) if o.ok()) {
3605            return None;
3606        }
3607        let branch_tracking = format!("{remote}/{branch}");
3608        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3609        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3610            return None;
3611        }
3612        if !crate::reconcile::origin_missing(&repo, head, &theirs)
3613            .await
3614            .is_ok_and(|missing| missing.is_empty())
3615        {
3616            return Some(format!(
3617                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3618                 them",
3619                short(&theirs)
3620            ));
3621        }
3622        match git::push_pinned(&repo, &remote, branch, &theirs).await {
3623            Ok(o) if o.ok() => {
3624                self.state
3625                    .event("land", format!("pushed rebased {branch} to {remote}"));
3626                None
3627            }
3628            Ok(o) => Some(format!(
3629                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3630                 someone may have pushed): {}",
3631                short(&theirs),
3632                o.stderr.chars().take(600).collect::<String>()
3633            )),
3634            Err(e) => Some(format!(
3635                "{branch} is rebased locally but could not be pushed: {e:#}"
3636            )),
3637        }
3638    }
3639
3640    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
3641    /// is already on `tip` under other commit ids ([`crate::already`]); returns
3642    /// whether it did.
3643    ///
3644    /// Checked only when the base is ahead of the branch. A failing check is
3645    /// "not proven" - the ordinary rebase path then decides - never a reason to
3646    /// stop the run.
3647    ///
3648    /// The remote copy of the branch is held to the same standard as the local
3649    /// one: if it carries a tip this worktree does not, that tip must itself be
3650    /// proven in the base, or nothing is settled (a pull request would
3651    /// otherwise be closed over commits nobody checked). The pull request is
3652    /// closed *before* the terminal status is saved; if that fails for a
3653    /// reason other than a refusal (no network, a `gh` error) the run is left
3654    /// `Blocked` with the reason as its conflict, which a resume retries -
3655    /// the same recovery a phantom conflict gets.
3656    async fn settle_already_in(
3657        &mut self,
3658        branch: &str,
3659        tip: &str,
3660        head: &str,
3661        attempts: usize,
3662        behind: usize,
3663    ) -> Result<bool> {
3664        let repo = self.state.repo.clone();
3665        let remote = self.state.config.merge.remote.clone();
3666        let start = self.state.base_commit.clone();
3667        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3668            Ok(Some(e)) => e,
3669            Ok(None) => return Ok(false),
3670            Err(e) => {
3671                tracing::warn!("already-in-base check for {branch}: {e:#}");
3672                return Ok(false);
3673            }
3674        };
3675        let mut verified = vec![head.to_owned()];
3676        let fetched = git::fetch(&repo, &remote, branch).await;
3677        if matches!(&fetched, Ok(o) if o.ok())
3678            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3679            && theirs != head
3680        {
3681            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3682                Ok(Some(_)) => verified.push(theirs),
3683                _ => return Ok(false),
3684            }
3685        }
3686        let base_branch = self.state.base_branch.clone();
3687        let message = format!(
3688            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3689             land",
3690            evidence.names(),
3691            evidence.proof.as_str()
3692        );
3693        let closed =
3694            crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3695        match closed {
3696            Ok(Ok(url)) => self
3697                .state
3698                .event("land", format!("closed {url}: superseded on {base_branch}")),
3699            Ok(Err(why)) => self
3700                .state
3701                .event("land", format!("did not close a pull request: {why}")),
3702            Err(e) => {
3703                let why = format!(
3704                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
3705                     not be closed ({e:#}); resume to retry"
3706                );
3707                self.state.status = RunStatus::Blocked;
3708                self.state.base_sync = Some(BaseSync {
3709                    tip: tip.to_owned(),
3710                    behind,
3711                    attempts,
3712                    conflict: Some(why.clone()),
3713                    already_in: None,
3714                });
3715                self.state.event("land", why);
3716                self.state.save()?;
3717                return Ok(true);
3718            }
3719        }
3720        self.state.status = RunStatus::AlreadyInBase;
3721        self.state.base_sync = Some(BaseSync {
3722            tip: tip.to_owned(),
3723            behind,
3724            attempts,
3725            conflict: None,
3726            already_in: Some(evidence),
3727        });
3728        self.state.event("land", message);
3729        self.state.save()?;
3730        self.settle_questions();
3731        Ok(true)
3732    }
3733
3734    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
3735    /// last landed the winner on, once it has run, else the commit the run
3736    /// branched from.
3737    ///
3738    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
3739    /// and `vote` all happen before there is a winner to rebase, so they
3740    /// compare every candidate against the branch point on purpose, and a
3741    /// base that moves after they are already done cannot change an answer
3742    /// they already gave.
3743    fn landing_base(&self) -> String {
3744        self.state
3745            .base_sync
3746            .as_ref()
3747            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3748    }
3749
3750    // ------------------------------------------------------- operator fix
3751
3752    /// Route specific, already-recorded review findings to a fixer for a
3753    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
3754    /// entry point.
3755    ///
3756    /// Distinct from `review_loop`'s own fix step in three ways: it never
3757    /// runs a reviewer wave, it never spends review-round budget, and what
3758    /// happened is recorded as an [`OperatorFixRequest`] appended to
3759    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
3760    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
3761    /// and vote must never be rewritten to look like a manufactured blocking
3762    /// verdict.
3763    ///
3764    /// Only meaningful once review has actually concluded: `Ready` (handed
3765    /// off with findings still open, or simply concluded clean while minor
3766    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
3767    /// gate failed). Everything else is refused: a run still in progress
3768    /// should simply be resumed, and a `Merged` run's branch has already
3769    /// landed — reopening *this* run's own record cannot change that, so the
3770    /// answer there is a fresh `magi review <branch>`.
3771    ///
3772    /// A real commit here re-verifies through a fresh, ordinary review-only
3773    /// run on the same branch ([`Self::review`]) rather than reopening this
3774    /// run's own `review_loop`: once any round in this run's history went
3775    /// clean, `review_conclusion` treats that as permanent by design (the
3776    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
3777    /// way to force one more genuine reviewer wave out of *this* run without
3778    /// either rewriting history or weakening that guarantee for every other
3779    /// caller. A review-only run costs nothing extra — no implementation, no
3780    /// judging, no vote — and exercises the exact same review → verify →
3781    /// gate → (human) merge path, unmodified.
3782    pub async fn fix_selected(
3783        &mut self,
3784        ids: &[String],
3785        reason: &str,
3786        allow_stale: bool,
3787    ) -> Result<()> {
3788        let reason = reason.trim();
3789        if reason.is_empty() {
3790            bail!("a fix request needs a reason — that is the operator's own record of why");
3791        }
3792        if ids.is_empty() {
3793            bail!("no finding id given");
3794        }
3795        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3796            bail!(
3797                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3798                 has already concluded — can be given a targeted fix. A run still \
3799                 in progress should simply be resumed; a `merged` run's branch has \
3800                 already landed, so its answer is a fresh `magi review <branch>`, \
3801                 not reopening this run's own record",
3802                self.state.id,
3803                self.state.status.as_str()
3804            );
3805        }
3806        let Some(winner) = self.state.winner().cloned() else {
3807            bail!("run {} has no winning candidate to fix", self.state.id);
3808        };
3809        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3810            bail!(
3811                "branch `{}` no longer exists; this run cannot be extended",
3812                winner.branch
3813            );
3814        }
3815        let home = crate::run::home();
3816        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3817            bail!(
3818                "run {} is currently being worked on by another magi process",
3819                self.state.id
3820            );
3821        }
3822        // Held for the rest of this call, including the follow-up review
3823        // below: two `magi fix` invocations against the same run must not
3824        // both reach the worktree manipulation further down, which would
3825        // otherwise race to remove and recreate the same directory — see
3826        // [`FixClaim`]'s own doc.
3827        let _claim = FixClaim::acquire(&self.state.dir())?;
3828
3829        // Resolve every id before spending anything — an unknown id refuses
3830        // the whole request rather than silently dropping it — and dedup
3831        // while keeping the operator's own order.
3832        let mut seen = BTreeSet::new();
3833        let mut findings = Vec::new();
3834        let mut missing = Vec::new();
3835        for id in ids {
3836            if !seen.insert(id.clone()) {
3837                continue;
3838            }
3839            match self.state.finding(id) {
3840                Some((round, rec, f)) => findings.push(OperatorFixFinding {
3841                    id: f.id.clone(),
3842                    severity: f.severity,
3843                    reviewer_vote: rec.vote,
3844                    round: round.round,
3845                    round_head: round.head.clone(),
3846                    reviewer: rec.reviewer,
3847                    agent: rec.agent.clone(),
3848                    file: f.file.clone(),
3849                    line: f.line,
3850                    title: f.title.clone(),
3851                    detail: f.detail.clone(),
3852                    outcome: OperatorFixOutcome::Pending,
3853                }),
3854                None => missing.push(id.clone()),
3855            }
3856        }
3857        if !missing.is_empty() {
3858            bail!(
3859                "unknown finding id(s): {}; nothing was changed",
3860                missing.join(", ")
3861            );
3862        }
3863
3864        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3865        let stale_details: Vec<(String, String)> = findings
3866            .iter()
3867            .filter(|f| f.round_head != head_at_request)
3868            .map(|f| (f.id.clone(), f.round_head.clone()))
3869            .collect();
3870        let stale = !stale_details.is_empty();
3871        if stale && !allow_stale {
3872            bail!(
3873                "the branch has moved since some finding(s) were raised — {} — now \
3874                 at {}; pass --allow-stale to fix anyway, or re-run review first",
3875                stale_details
3876                    .iter()
3877                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
3878                    .collect::<Vec<_>>()
3879                    .join(", "),
3880                short(&head_at_request)
3881            );
3882        }
3883
3884        let request = OperatorFixRequest {
3885            requested_at: Timestamp::now(),
3886            reason: reason.to_owned(),
3887            findings,
3888            head_at_request: head_at_request.clone(),
3889            allow_stale,
3890            stale,
3891            fix: None,
3892            result_head: None,
3893            follow_up_review_run: None,
3894        };
3895        self.state.event(
3896            "fix",
3897            format!(
3898                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
3899                request.findings.len(),
3900                request
3901                    .findings
3902                    .iter()
3903                    .map(|f| f.id.as_str())
3904                    .collect::<Vec<_>>()
3905                    .join(", "),
3906            ),
3907        );
3908        // Recorded now, before any worktree work or the fixer call itself —
3909        // and re-saved at each checkpoint below: a crash at any point after
3910        // this (mid fixer call, mid follow-up review) must not lose the fact
3911        // that this was requested, for which findings, and why. Everything
3912        // past this point reads and writes through `request_index` rather
3913        // than a local variable, since `request` itself is moved here.
3914        self.state.operator_fixes.push(request);
3915        self.state.save()?;
3916        let request_index = self.state.operator_fixes.len() - 1;
3917
3918        // A fresh, dedicated worktree for this one call, never the winner's
3919        // own worktree in place: that one may already be gone (folded away),
3920        // and reusing it in place would leave the branch checked out there
3921        // when the follow-up review below tries to check it out again. Freed
3922        // immediately after, either way — but only once confirmed clean:
3923        // `worktree_remove` is a `git worktree remove --force`, which would
3924        // otherwise discard uncommitted work left there by the operator or
3925        // another process before this had a chance to even look at it.
3926        if winner.worktree.exists() {
3927            // Lockfiles a rescue commit withheld stay untracked on purpose and
3928            // are already recorded; they are not the operator's work to protect.
3929            let dirty = git::git(
3930                &winner.worktree,
3931                &["status", "--porcelain", "--untracked-files=all"],
3932            )
3933            .await?;
3934            let only_withheld = dirty.lines().all(|l| {
3935                l.strip_prefix("?? ")
3936                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
3937            });
3938            if !only_withheld {
3939                bail!(
3940                    "`{}` has uncommitted changes; refusing to touch it — commit or \
3941                     discard them first",
3942                    winner.worktree.display()
3943                );
3944            }
3945            git::worktree_remove(&self.state.repo, &winner.worktree)
3946                .await
3947                .ok();
3948        }
3949        let fix_worktree = self.state.worktree_root().join("operator-fix");
3950        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
3951        git::git(
3952            &self.state.repo,
3953            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
3954        )
3955        .await
3956        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
3957        if !git::is_clean(&fix_worktree).await? {
3958            git::worktree_remove(&self.state.repo, &fix_worktree)
3959                .await
3960                .ok();
3961            bail!(
3962                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
3963                winner.branch
3964            );
3965        }
3966
3967        let run_id = self.state.id.clone();
3968        let prompts = self.state.config.prompts.clone();
3969        let language = self.state.config.graph.language.clone();
3970        let sessions = self.state.config.graph.sessions;
3971        let artifacts = agent::artifacts_dir(&self.state.dir());
3972        let (fix_spec, fix_seat_key) = match &self.roles.fixer {
3973            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
3974            _ => (
3975                self.state
3976                    .config
3977                    .agent(&winner.agent)
3978                    .cloned()
3979                    .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
3980                format!("impl-{}", winner.label),
3981            ),
3982        };
3983        let seat = self.seat(&fix_seat_key, &fix_spec.id);
3984        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
3985            .findings
3986            .iter()
3987            .map(|f| Finding {
3988                id: f.id.clone(),
3989                severity: f.severity,
3990                file: f.file.clone(),
3991                line: f.line,
3992                title: f.title.clone(),
3993                detail: f.detail.clone(),
3994            })
3995            .collect();
3996        let job = SeatJob {
3997            prompt: prompt::operator_fix(
3998                &self.state.instruction,
3999                &finding_list,
4000                reason,
4001                &stale_details,
4002                &head_at_request,
4003                &language,
4004            ),
4005            spec: fix_spec.clone(),
4006            seat,
4007            cwd: fix_worktree.clone(),
4008            timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4009            allow_write: true,
4010            sessions,
4011            artifacts: artifacts.clone(),
4012            stem: "operator-fix".to_owned(),
4013        };
4014        let cache = self.state.config.cache_dir();
4015        let ctx = WaveCtx {
4016            run: &run_id,
4017            node: "fix",
4018            prompts: &prompts,
4019            cache: cache.as_deref(),
4020            round: None,
4021        };
4022        let (seat, out) =
4023            run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4024        let agent_id = seat.agent.clone();
4025
4026        let mut fix = FixRecord {
4027            agent: agent_id,
4028            addressed: Vec::new(),
4029            rejected: Vec::new(),
4030            notes: String::new(),
4031            committed: false,
4032            failed: None,
4033            duration_ms: 0,
4034            continuation: None,
4035        };
4036        let mut final_seat = seat.clone();
4037        match out {
4038            AgentOutcome::Ok(o) => {
4039                fix.duration_ms = o.duration_ms;
4040                let parsed = verdict::extract_json::<FixReport>(&o.text);
4041                let incomplete_reason = match &parsed {
4042                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4043                        "the reply parsed, but it reported a command whose own CLI \
4044                         never confirmed an exit status"
4045                            .to_owned(),
4046                    ),
4047                    Ok(_) => None,
4048                    Err(e) => Some(e.to_string()),
4049                };
4050                match incomplete_reason {
4051                    None => {
4052                        let report = parsed.expect("checked Ok above");
4053                        fix.addressed = report.addressed;
4054                        fix.rejected = report.rejected;
4055                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4056                    }
4057                    Some(reason) => {
4058                        let (resumed_seat, resolved, failure, cont) = self
4059                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4060                            .await;
4061                        fix.duration_ms += cont.cumulative_wait_ms;
4062                        fix.continuation = Some(cont);
4063                        final_seat = resumed_seat;
4064                        match resolved {
4065                            Some(report) => {
4066                                fix.addressed = report.addressed;
4067                                fix.rejected = report.rejected;
4068                                fix.notes =
4069                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4070                            }
4071                            None => fix.failed = failure,
4072                        }
4073                    }
4074                }
4075            }
4076            AgentOutcome::Dropped(o) => {
4077                fix.duration_ms = o.duration_ms;
4078                let why = o
4079                    .dropped
4080                    .as_ref()
4081                    .map(|d| d.why.as_str())
4082                    .unwrap_or("the CLI ended the stream without delivering its answer");
4083                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4084            }
4085            AgentOutcome::Quota(o) => {
4086                self.state.quota.push(QuotaLoss {
4087                    seat: final_seat.key.clone(),
4088                    node: "fix".to_owned(),
4089                    at: Timestamp::now(),
4090                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4091                });
4092                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4093            }
4094            AgentOutcome::Failed(e) => fix.failed = Some(e),
4095        }
4096        if fix.continuation.is_none() {
4097            fix.continuation = Some(ContinuationRecord::not_needed());
4098        }
4099        self.state.seats.insert(final_seat.key.clone(), final_seat);
4100
4101        let rescue_message = format!(
4102            "magi: operator-selected fix ({}) (uncommitted work)",
4103            self.state.operator_fixes[request_index]
4104                .findings
4105                .iter()
4106                .map(|f| f.id.as_str())
4107                .collect::<Vec<_>>()
4108                .join(", ")
4109        );
4110        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4111            self.state.note_withheld("fix", &r.withheld);
4112        }
4113        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4114        fix.committed = after != head_at_request;
4115        git::worktree_remove(&self.state.repo, &fix_worktree)
4116            .await
4117            .ok();
4118
4119        self.state.event(
4120            "fix",
4121            match &fix.failed {
4122                Some(reason) => format!(
4123                    "operator fix: adoption report was lost ({reason}); {}",
4124                    if fix.committed {
4125                        "committed"
4126                    } else {
4127                        "NO new commit"
4128                    }
4129                ),
4130                None => format!(
4131                    "operator fix: {} addressed, {} rejected, {}",
4132                    fix.addressed.len(),
4133                    fix.rejected.len(),
4134                    if fix.committed {
4135                        "committed"
4136                    } else {
4137                        "NO new commit"
4138                    }
4139                ),
4140            },
4141        );
4142
4143        // Every selected finding gets an outcome — never left `Pending` once
4144        // the fixer's own turn is over. A report that never came back at all
4145        // marks every one of them `Unreported`, not silently "not addressed":
4146        // quota, a dropped stream, or an exhausted continuation are gaps in
4147        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4148        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4149        for f in &mut self.state.operator_fixes[request_index].findings {
4150            f.outcome = if fix.failed.is_some() {
4151                OperatorFixOutcome::Unreported
4152            } else if fix.addressed.contains(&f.id) {
4153                OperatorFixOutcome::Addressed
4154            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4155                OperatorFixOutcome::Rejected { why: r.why.clone() }
4156            } else {
4157                OperatorFixOutcome::Unreported
4158            };
4159        }
4160
4161        let committed = fix.committed;
4162        if committed {
4163            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4164        }
4165        self.state.operator_fixes[request_index].fix = Some(fix);
4166        // Saved again now that the fixer's own outcome is final, on top of
4167        // the save right after the request was first pushed above.
4168        self.state.save()?;
4169
4170        if committed {
4171            self.state.event(
4172                "fix",
4173                format!(
4174                    "operator fix committed {}; opening a follow-up review-only run",
4175                    short(&after)
4176                ),
4177            );
4178            // The operator asked for the fix, and the follow-up serves whatever
4179            // task the run it follows served.
4180            let origin =
4181                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4182            match Self::review(
4183                &self.state.repo,
4184                &winner.branch,
4185                self.state.config.clone(),
4186                origin,
4187            )
4188            .await
4189            {
4190                Ok(mut follow_up) => {
4191                    follow_up.state.event(
4192                        "start",
4193                        format!(
4194                            "requested by an operator fix on run {} for finding(s) {}",
4195                            self.state.id,
4196                            self.state.operator_fixes[request_index]
4197                                .findings
4198                                .iter()
4199                                .map(|f| f.id.as_str())
4200                                .collect::<Vec<_>>()
4201                                .join(", "),
4202                        ),
4203                    );
4204                    follow_up.state.save()?;
4205                    let follow_up_id = follow_up.state.id.clone();
4206                    if let Err(e) = follow_up.execute().await {
4207                        self.state.event(
4208                            "fix",
4209                            format!(
4210                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4211                            ),
4212                        );
4213                    }
4214                    self.state.operator_fixes[request_index].follow_up_review_run =
4215                        Some(follow_up_id);
4216                }
4217                Err(e) => {
4218                    self.state.event(
4219                        "fix",
4220                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4221                    );
4222                }
4223            }
4224            self.state.save()?;
4225        }
4226
4227        Ok(())
4228    }
4229
4230    // --------------------------------------------------------------- review
4231
4232    /// The agent and seat key that fix the winner's tree: the configured
4233    /// fixer, else the winner's own implementer seat, whose conversation
4234    /// continues now that the competition is over. Shared by the review loop
4235    /// and the gate-fix round so both talk to the same seat.
4236    fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4237        match &self.roles.fixer {
4238            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4239            _ => (
4240                self.state
4241                    .config
4242                    .agent(&winner.agent)
4243                    .cloned()
4244                    .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4245                format!("impl-{}", winner.label),
4246            ),
4247        }
4248    }
4249
4250    async fn review_loop(&mut self) -> Result<()> {
4251        // A base that would not rebase is a person's decision, not a review
4252        // round: nothing here would change the answer, and reviewers and a
4253        // fixer would be spending real budget on a tree that cannot land
4254        // regardless of what they find.
4255        if self
4256            .state
4257            .base_sync
4258            .as_ref()
4259            .is_some_and(|s| s.conflict.is_some())
4260        {
4261            return Ok(());
4262        }
4263        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4264        // agent files with `magi task add` name the run that paid for it. The
4265        // prompt overlay is cloned alongside it because the waves borrow it
4266        // while `self` is mutably borrowed by the node's own bookkeeping.
4267        let run_id = self.state.id.clone();
4268        let prompts = self.state.config.prompts.clone();
4269        let Some(winner) = self.state.winner().cloned() else {
4270            return Ok(());
4271        };
4272        let max_rounds = self.state.config.graph.review_rounds;
4273        // A clean round, an exhausted round budget, or a stalled tree (see
4274        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4275        // they are recorded — recomputed here, not read off `status`, so a
4276        // reentry into a run that already stopped restates the identical
4277        // verdict instead of silently handing back whatever an earlier node
4278        // in this same walk clobbered `status` to (a solo-candidate
4279        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4280        // below runs an empty range once the budget is spent, and would
4281        // otherwise fall through without touching `status` at all.
4282        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4283            // A reentry after a crash between the last round's save and
4284            // `stop_reviewing` reaches the hand-off here, not there.
4285            if status == RunStatus::Gating {
4286                self.record_contested_handoff();
4287            }
4288            self.state.status = status;
4289            self.state.save()?;
4290            return Ok(());
4291        }
4292        self.state.status = RunStatus::Reviewing;
4293        // A last recorded round whose own verification never resolved
4294        // (`ResourceBlocked` — the shared build cache, not the patch) is
4295        // never a concluded round, whatever the round budget says: starting
4296        // a fresh round on top of it would spend a whole new reviewer wave
4297        // re-reading an unchanged patch instead of just retrying the one
4298        // check that actually needs it, and once the budget is spent the
4299        // loop below has nothing left to do at all (its range is empty).
4300        // Retry that check directly instead, exactly the same retry
4301        // `stop_reviewing` already does for its own catch-up case.
4302        if self
4303            .state
4304            .reviews
4305            .last()
4306            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4307        {
4308            let shell = self.state.config.shell();
4309            return self
4310                .stop_reviewing(
4311                    "the last round's own verification never resolved",
4312                    &shell,
4313                    &winner.worktree,
4314                )
4315                .await;
4316        }
4317
4318        let repo = self.state.repo.clone();
4319        let root = self.state.worktree_root();
4320        let language = self.state.config.graph.language.clone();
4321        let sessions = self.state.config.graph.sessions;
4322        let artifacts = agent::artifacts_dir(&self.state.dir());
4323        let base = self.landing_base();
4324        let base_short = short(&base);
4325        let reviewers = self.roles.reviewers.clone();
4326        let shell = self.state.config.shell();
4327
4328        for round in (self.state.reviews.len() + 1)..=max_rounds {
4329            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4330            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4331            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4332            // The prior round's own record, already persisted — never a
4333            // hand-carried variable of just its failing output: that is
4334            // exactly what let a round's e2e result drift out of sync with
4335            // which commit it was actually about (see `SCHEMA`'s doc for
4336            // schema 8). Judged against `head`, the commit reviewers are
4337            // about to look at now, so the summary always reads as "an
4338            // earlier head" here — this round's own patch has not been
4339            // checked yet.
4340            let prev_verification = self
4341                .state
4342                .reviews
4343                .last()
4344                .and_then(|r| r.verification_summary(&head));
4345
4346            // Each reviewer gets its own detached checkout of exactly this
4347            // commit: nobody can perturb the winner's tree, and the fixer can
4348            // keep working without racing a reviewer.
4349            let mut jobs = Vec::new();
4350            for (r, spec) in reviewers.iter().cloned().enumerate() {
4351                let wt = root.join(format!("review-{}", r + 1));
4352                if wt.exists() {
4353                    git::reset_detached(&wt, &head).await?;
4354                } else {
4355                    git::worktree_add_detached(&repo, &wt, &head).await?;
4356                }
4357                let seat_key = format!("review-{}", r + 1);
4358                let seat = self.seat(&seat_key, &spec.id);
4359                jobs.push(SeatJob {
4360                    prompt: prompt::review(&prompt::ReviewCtx {
4361                        instruction: &self.state.instruction,
4362                        branch: &winner.branch,
4363                        base_short: &base_short,
4364                        stat: &stat,
4365                        patch: &patch,
4366                        verification: prev_verification.as_ref(),
4367                        reviewers: reviewers.len(),
4368                        round,
4369                        rounds: max_rounds,
4370                        // A review-only run has no rankings, so nothing
4371                        // competed for this patch and the reviewer is told so.
4372                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4373                        lens: Lens::for_seat(r),
4374                        language: &language,
4375                    }),
4376                    spec,
4377                    seat,
4378                    cwd: wt,
4379                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4380                    allow_write: false,
4381                    sessions,
4382                    artifacts: artifacts.clone(),
4383                    stem: format!("review-{round}-{}", r + 1),
4384                });
4385            }
4386
4387            self.state.event(
4388                "review",
4389                format!(
4390                    "round {round}: {} reviewers on {}",
4391                    jobs.len(),
4392                    short(&head)
4393                ),
4394            );
4395            let mut quota_losses = Vec::new();
4396            let review_retries = self.state.config.graph.retries;
4397            let review_cache = self.state.config.cache_dir();
4398            let ctx = WaveCtx {
4399                run: &run_id,
4400                node: "review",
4401                prompts: &prompts,
4402                cache: review_cache.as_deref(),
4403                round: Some(round),
4404            };
4405            let results = ask_json_wave::<Review>(
4406                jobs,
4407                Arc::clone(&self.sem),
4408                review_retries,
4409                &self.roles.reviewer_roster,
4410                &ctx,
4411                &mut quota_losses,
4412                &mut self.state,
4413                &|_: &Review| Ok(()),
4414            )
4415            .await;
4416            // Counted before the move below: how many of *this* round's
4417            // reviewer seats were lost to their own rate limit, as opposed to
4418            // a crash, a timeout, or unparsable output — see `round_is_clean`.
4419            let round_quota_missing = quota_losses.len();
4420            self.state.quota.extend(quota_losses);
4421
4422            let mut records = Vec::new();
4423            let mut all_findings = Vec::new();
4424            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4425                let agent_id = seat.agent.clone();
4426                self.state.seats.insert(seat.key.clone(), seat);
4427                let mut record = ReviewRecord {
4428                    reviewer: r + 1,
4429                    agent: agent_id,
4430                    summary: String::new(),
4431                    findings: Vec::new(),
4432                    vote: None,
4433                    failed: None,
4434                    duration_ms: 0,
4435                    // Set for both outcomes: `failed: Some(_)` with
4436                    // `attempts > 0` is a seat every retry still lost, not a
4437                    // recovered one — only `failed: None` with `attempts > 0`
4438                    // reads as "answered after a nudge" (see this field's own
4439                    // doc).
4440                    attempts,
4441                };
4442                match res {
4443                    Ok((review, out)) => {
4444                        // Sanitized here, at the point every other piece of
4445                        // agent prose in this file is (candidate summaries,
4446                        // deliberation turns, vote reasons): a reviewer's own
4447                        // words are the one thing about it that could name
4448                        // it, and reconsideration below broadcasts this same
4449                        // summary and these same findings to every other
4450                        // seat on the panel.
4451                        record.summary =
4452                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
4453                        record.vote = Some(review.vote);
4454                        record.duration_ms = out.duration_ms;
4455                        for (n, mut f) in review.findings.into_iter().enumerate() {
4456                            // ids are magi's, never the agent's: the fixer's
4457                            // adoption report is keyed by them.
4458                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4459                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4460                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4461                            // `file` is agent-supplied prose too, never
4462                            // checked against the real tree — the same
4463                            // exposure `title`/`detail` above have, just in
4464                            // a field easy to forget because it looks like a
4465                            // path rather than free text.
4466                            f.file = f
4467                                .file
4468                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4469                            all_findings.push(f.clone());
4470                            record.findings.push(f);
4471                        }
4472                        self.state.event(
4473                            "review",
4474                            format!(
4475                                "round {round}: reviewer {} voted {} with {} finding(s)",
4476                                r + 1,
4477                                review.vote.label(),
4478                                record.findings.len()
4479                            ),
4480                        );
4481                    }
4482                    Err(e) => {
4483                        record.failed = Some(e.to_string());
4484                        self.state.event(
4485                            "review",
4486                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4487                        );
4488                    }
4489                }
4490                records.push(record);
4491            }
4492
4493            // Tally the round's votes and, if they split, spend the one
4494            // round of reconsideration the split -> deliberate -> revote
4495            // shape `judge`/`vote` use for the panel, sized down to what a
4496            // read-only review round can afford: one round, and a revote
4497            // rather than an argument, because the panel already wrote its
4498            // reasoning down as findings the first time around.
4499            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4500            let vote_split =
4501                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4502            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4503            if vote_split {
4504                self.state.event(
4505                    "review",
4506                    format!(
4507                        "round {round}: votes split ({}) — one round of reconsideration",
4508                        initial_votes
4509                            .iter()
4510                            .map(|v| v.label())
4511                            .collect::<Vec<_>>()
4512                            .join(", ")
4513                    ),
4514                );
4515                // Seats read every seat's findings and votes, still numbered
4516                // and never named — the same anonymity `review` itself keeps.
4517                let panel: Vec<ReviewSeatReport<'_>> = records
4518                    .iter()
4519                    .filter_map(|r| {
4520                        r.vote.map(|vote| ReviewSeatReport {
4521                            reviewer: r.reviewer,
4522                            vote,
4523                            summary: &r.summary,
4524                            findings: &r.findings,
4525                        })
4526                    })
4527                    .collect();
4528
4529                let mut jobs = Vec::new();
4530                let mut seats_at = Vec::new();
4531                for (r, spec) in reviewers.iter().cloned().enumerate() {
4532                    // A seat with no initial vote has nothing to reconsider
4533                    // from and stays absent, the same as it stayed absent
4534                    // from `panel` above.
4535                    if records[r].vote.is_none() {
4536                        continue;
4537                    }
4538                    let wt = root.join(format!("review-{}", r + 1));
4539                    let seat_key = format!("review-{}", r + 1);
4540                    let spec = self.occupant(&seat_key, spec);
4541                    let seat = self.seat(&seat_key, &spec.id);
4542                    // A seat with no live session has already forgotten the
4543                    // initial review's prompt — restate the patch it is
4544                    // voting on, the same as `deliberate`/`vote` do for a
4545                    // judge in the same position.
4546                    let patch_ctx = if has_context(&spec, &seat, sessions) {
4547                        None
4548                    } else {
4549                        Some(ReviewPatch {
4550                            branch: &winner.branch,
4551                            base_short: &base_short,
4552                            stat: &stat,
4553                            patch: &patch,
4554                        })
4555                    };
4556                    let prompt = prompt::review_reconsider(&ReviewReconsiderCtx {
4557                        instruction: &self.state.instruction,
4558                        reviewer: r + 1,
4559                        lens: Lens::for_seat(r),
4560                        panel: &panel,
4561                        patch: patch_ctx,
4562                        round,
4563                        rounds: max_rounds,
4564                        language: &language,
4565                    });
4566                    jobs.push(SeatJob {
4567                        prompt,
4568                        spec,
4569                        seat,
4570                        cwd: wt,
4571                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4572                        allow_write: false,
4573                        sessions,
4574                        artifacts: artifacts.clone(),
4575                        stem: format!("review-{round}-reconsider-{}", r + 1),
4576                    });
4577                    seats_at.push(r);
4578                }
4579
4580                let mut recon_quota_losses = Vec::new();
4581                let recon_cache = self.state.config.cache_dir();
4582                let recon_ctx = WaveCtx {
4583                    run: &run_id,
4584                    node: "review",
4585                    prompts: &prompts,
4586                    cache: recon_cache.as_deref(),
4587                    round: Some(round),
4588                };
4589                let recon_results = ask_json_wave::<ReviewRevote>(
4590                    jobs,
4591                    Arc::clone(&self.sem),
4592                    review_retries,
4593                    &[],
4594                    &recon_ctx,
4595                    &mut recon_quota_losses,
4596                    &mut self.state,
4597                    &|_: &ReviewRevote| Ok(()),
4598                )
4599                .await;
4600                self.state.quota.extend(recon_quota_losses);
4601
4602                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4603                    let agent_id = seat.agent.clone();
4604                    self.state.seats.insert(seat.key.clone(), seat);
4605                    let mut rec = ReviewRevoteRecord {
4606                        reviewer: r + 1,
4607                        agent: agent_id,
4608                        vote: None,
4609                        reason: String::new(),
4610                        failed: None,
4611                    };
4612                    match res {
4613                        Ok((rv, _)) => {
4614                            rec.vote = Some(rv.vote);
4615                            rec.reason =
4616                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4617                            self.state.event(
4618                                "review",
4619                                format!(
4620                                    "round {round}: reviewer {} revoted {}",
4621                                    r + 1,
4622                                    rv.vote.label()
4623                                ),
4624                            );
4625                        }
4626                        Err(e) => {
4627                            rec.failed = Some(e.to_string());
4628                            self.state.event(
4629                                "review",
4630                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4631                            );
4632                        }
4633                    }
4634                    reconsideration.push(rec);
4635                }
4636            } else if initial_votes.len() > 1 {
4637                self.state.event(
4638                    "review",
4639                    format!(
4640                        "round {round}: votes agreed ({}) — no reconsideration",
4641                        initial_votes[0].label()
4642                    ),
4643                );
4644            }
4645
4646            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4647            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4648            // A round that already has a blocking finding and a round left to
4649            // try is going back to the fixer no matter what `verify.e2e`
4650            // says, so running it first only spends the loop's slowest step
4651            // (minutes, for a Rust repo's full test suite) on a head about
4652            // to be rewritten. Deferred, never skipped: `verify.e2e` still
4653            // runs once a round has no blocking findings left (see
4654            // `round_is_clean`, which a deferred — empty — `e2e` can never
4655            // satisfy since `blocking` is nonzero whenever this branch is
4656            // taken), and `stop_reviewing` forces a real run before it will
4657            // ever read a deferred round as green.
4658            let defer_e2e =
4659                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4660            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4661                let reason =
4662                    format!("{blocking} blocking finding(s) already required a fix this round");
4663                self.state.event(
4664                    "verify",
4665                    format!(
4666                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4667                         {}); it will run once a round has none left",
4668                        short(&head)
4669                    ),
4670                );
4671                (Vec::new(), false, true, Some(reason))
4672            } else {
4673                let e2e_commands = self.state.config.verify.e2e.clone();
4674                let cache_dir = self.state.config.cache_dir();
4675                let context = format!("round {round}");
4676                let (e2e, verify_retried) = with_cache_lease(
4677                    &mut self.state,
4678                    cache_dir.as_deref(),
4679                    "e2e",
4680                    "e2e",
4681                    &winner.worktree,
4682                    &head,
4683                    verify_timeout,
4684                    &context,
4685                    |state, budget| {
4686                        let shell = shell.clone();
4687                        let e2e_commands = e2e_commands.clone();
4688                        let worktree = winner.worktree.clone();
4689                        let context = context.clone();
4690                        async move {
4691                            run_e2e_with_retry(
4692                                state,
4693                                &shell,
4694                                &e2e_commands,
4695                                &worktree,
4696                                budget,
4697                                &context,
4698                            )
4699                            .await
4700                        }
4701                    },
4702                )
4703                .await;
4704                (e2e, verify_retried, false, None)
4705            };
4706
4707            let expected = records.len();
4708            let answered = records.iter().filter(|r| r.failed.is_none()).count();
4709            let incomplete = answered < expected;
4710            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4711            let policy = self.state.config.graph.incomplete_review;
4712            let clean = round_is_clean(
4713                blocking,
4714                e2e_ok,
4715                answered,
4716                expected,
4717                round_quota_missing,
4718                policy,
4719            );
4720
4721            let mut round_record = ReviewRound {
4722                round,
4723                head: head.clone(),
4724                verified_head: None,
4725                verified_at: None,
4726                reviews: records,
4727                e2e,
4728                verify_retried,
4729                e2e_deferred,
4730                e2e_defer_reason,
4731                fix: None,
4732                blocking,
4733                answered,
4734                expected,
4735                clean,
4736                progressed: false,
4737                vote_split,
4738                reconsideration,
4739                verdict: None,
4740            };
4741            // The final vote per seat is its revote where reconsideration
4742            // ran and answered, its initial vote otherwise — the same
4743            // fallback `tally` uses for a judge whose private vote failed.
4744            round_record.verdict = ReviewVote::worst(
4745                round_record
4746                    .final_votes()
4747                    .into_iter()
4748                    .map(|(_, _, vote)| vote),
4749            );
4750            // Which commit and when magi actually attempted to check —
4751            // known the moment a command was dispatched against `head`,
4752            // whether or not it finished: a resource-blocked attempt still
4753            // targeted a specific commit at a specific time, and leaving
4754            // that unrecorded is exactly what made `verification_summary`
4755            // report a fresh attempt as "commit unknown ... recorded before
4756            // this was tracked", indistinguishable from a genuinely old,
4757            // untracked record. Only a deferred or unconfigured round never
4758            // ran at all and has nothing to record — see
4759            // `ReviewRound::verified_head`'s own doc.
4760            if !matches!(
4761                round_record.e2e_status(),
4762                E2eStatus::Deferred | E2eStatus::NotConfigured
4763            ) {
4764                round_record.verified_head = Some(head.clone());
4765                round_record.verified_at = Some(Timestamp::now());
4766            }
4767            let this_round_verification = round_record.verification_summary(&head);
4768
4769            if incomplete {
4770                let missing: Vec<String> = round_record
4771                    .reviews
4772                    .iter()
4773                    .filter(|r| r.failed.is_some())
4774                    .map(|r| format!("review-{}", r.reviewer))
4775                    .collect();
4776                self.state.event(
4777                    "review",
4778                    format!(
4779                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4780                        missing.join(", ")
4781                    ),
4782                );
4783            }
4784
4785            if clean {
4786                self.state.event(
4787                    "review",
4788                    if incomplete && policy == IncompleteReviewPolicy::Warn {
4789                        format!(
4790                            "round {round}: clean (warn policy, incomplete panel) — no \
4791                             blocking findings from the seats that answered, verification green"
4792                        )
4793                    } else if incomplete {
4794                        format!(
4795                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4796                             quorum) — no blocking findings from the seats that answered, \
4797                             verification green",
4798                            expected - answered
4799                        )
4800                    } else {
4801                        format!("round {round}: clean — no blocking findings, verification green")
4802                    },
4803                );
4804                self.state.reviews.push(round_record);
4805                self.state.status = RunStatus::Gating;
4806                self.state.save()?;
4807                return Ok(());
4808            }
4809
4810            // Nothing was raised and verification passed, but not every seat
4811            // answered and `round_is_clean` still refused to call it clean —
4812            // either a seat is missing for a reason other than its own quota
4813            // (a crash, a timeout, unparsable output — worth another try), or
4814            // every seat that could have answered lost its quota and nobody
4815            // is left to decide on: re-review rather than send the fixer
4816            // after a round with nothing to fix.
4817            if incomplete && blocking == 0 && e2e_ok {
4818                self.state.reviews.push(round_record);
4819                self.state.save()?;
4820                if round == max_rounds {
4821                    self.state.status = RunStatus::Blocked;
4822                    self.state.event(
4823                        "review",
4824                        format!(
4825                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4826                             refusing to call it clean",
4827                            expected - answered
4828                        ),
4829                    );
4830                    return Ok(());
4831                }
4832                continue;
4833            }
4834
4835            // Nothing for the fixer to act on (`blocking == 0`) and the only
4836            // reason this round is not clean is that magi itself never got
4837            // a command to run — the shared build cache, not the patch (see
4838            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
4839            // the fixer would invite a change to appease contention that has
4840            // nothing to do with the diff, and would leave this attempt
4841            // sitting in the next round's prompt as if it were about an
4842            // earlier, superseded commit rather than what it actually is:
4843            // the same head, still waiting to be checked. Wait for it the
4844            // same way the final round's own contention is already handled,
4845            // whatever round this happens to be.
4846            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
4847                self.state.reviews.push(round_record);
4848                return self
4849                    .stop_reviewing(
4850                        "the round's own verification could not run",
4851                        &shell,
4852                        &winner.worktree,
4853                    )
4854                    .await;
4855            }
4856
4857            if round == max_rounds {
4858                self.state.reviews.push(round_record);
4859                return self
4860                    .stop_reviewing(
4861                        &format!(
4862                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
4863                        ),
4864                        &shell,
4865                        &winner.worktree,
4866                    )
4867                    .await;
4868            }
4869
4870            // Fix. The winner's own implementer seat continues its conversation:
4871            // the competition is over, so context is pure benefit now.
4872            let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
4873            let seat = self.seat(&fix_seat_key, &fix_spec.id);
4874            let blocking_findings: Vec<_> = all_findings
4875                .iter()
4876                .filter(|f| f.severity.blocks())
4877                .cloned()
4878                .collect();
4879            let job = SeatJob {
4880                prompt: prompt::fix(
4881                    &self.state.instruction,
4882                    &blocking_findings,
4883                    this_round_verification.as_ref(),
4884                    round,
4885                    max_rounds,
4886                    &language,
4887                ),
4888                spec: fix_spec.clone(),
4889                seat,
4890                cwd: winner.worktree.clone(),
4891                timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4892                allow_write: true,
4893                sessions,
4894                artifacts: artifacts.clone(),
4895                stem: format!("fix-{round}"),
4896            };
4897            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
4898            let cache = self.state.config.cache_dir();
4899            let ctx = WaveCtx {
4900                run: &run_id,
4901                node: "fix",
4902                prompts: &prompts,
4903                cache: cache.as_deref(),
4904                round: Some(round),
4905            };
4906            let (seat, out) =
4907                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4908            let agent_id = seat.agent.clone();
4909
4910            let mut fix = FixRecord {
4911                agent: agent_id,
4912                addressed: Vec::new(),
4913                rejected: Vec::new(),
4914                notes: String::new(),
4915                committed: false,
4916                failed: None,
4917                duration_ms: 0,
4918                continuation: None,
4919            };
4920            let mut continuation = ContinuationRecord::not_needed();
4921            let mut final_seat = seat.clone();
4922            match out {
4923                AgentOutcome::Ok(o) => {
4924                    fix.duration_ms = o.duration_ms;
4925                    let parsed = verdict::extract_json::<FixReport>(&o.text);
4926                    // A parsed report standing next to a command this same
4927                    // reply's own CLI never confirmed the exit status of is
4928                    // not a resolved answer — the identical `CommandEvidence`
4929                    // `state.jobs` renders, read here instead of only on
4930                    // display, per the completion judgment and the shown
4931                    // record needing to agree.
4932                    let incomplete_reason = match &parsed {
4933                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4934                            "the reply parsed, but it reported a command whose own CLI never \
4935                             confirmed an exit status"
4936                                .to_owned(),
4937                        ),
4938                        Ok(_) => None,
4939                        Err(e) => Some(e.to_string()),
4940                    };
4941                    match incomplete_reason {
4942                        None => {
4943                            let report = parsed.expect("checked Ok above");
4944                            fix.addressed = report.addressed;
4945                            fix.rejected = report.rejected;
4946                            fix.notes =
4947                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
4948                        }
4949                        Some(reason) => {
4950                            let (resumed_seat, resolved, failure, cont) = self
4951                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
4952                                .await;
4953                            fix.duration_ms += cont.cumulative_wait_ms;
4954                            continuation = cont;
4955                            final_seat = resumed_seat;
4956                            match resolved {
4957                                Some(report) => {
4958                                    fix.addressed = report.addressed;
4959                                    fix.rejected = report.rejected;
4960                                    fix.notes = blind::sanitize_prose(
4961                                        &report.notes,
4962                                        &self.state.config.blind,
4963                                    );
4964                                }
4965                                None => fix.failed = failure,
4966                            }
4967                        }
4968                    }
4969                }
4970                // The CLI's raw error JSON is not a fix report to parse.
4971                AgentOutcome::Dropped(o) => {
4972                    fix.duration_ms = o.duration_ms;
4973                    let why = o
4974                        .dropped
4975                        .as_ref()
4976                        .map(|d| d.why.as_str())
4977                        .unwrap_or("the CLI ended the stream without delivering its answer");
4978                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4979                }
4980                AgentOutcome::Quota(o) => {
4981                    self.state.quota.push(QuotaLoss {
4982                        seat: final_seat.key.clone(),
4983                        node: "fix".to_owned(),
4984                        at: Timestamp::now(),
4985                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4986                    });
4987                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4988                }
4989                AgentOutcome::Failed(e) => fix.failed = Some(e),
4990            }
4991            fix.continuation = Some(continuation);
4992            self.state.seats.insert(final_seat.key.clone(), final_seat);
4993            if let Ok(r) = git::rescue_commit(
4994                &winner.worktree,
4995                &format!("magi: review round {round} fixes (uncommitted work)"),
4996            )
4997            .await
4998            {
4999                self.state.note_withheld("fix", &r.withheld);
5000            }
5001            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5002            fix.committed = after != before;
5003            // Judged by what `git` says moved against base, never by the
5004            // fixer's own `addressed`/`rejected` count — see
5005            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5006            // `patch` snapshot above: swallowing this error would default
5007            // `diff_after` to empty, which almost always differs from a
5008            // non-empty `patch` and reads as "progressed" — exactly backwards
5009            // for a `git` failure the stagnation check cannot see through.
5010            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5011            let progressed = diff_after != patch;
5012            let commit_note = if fix.committed {
5013                "committed"
5014            } else {
5015                "NO new commit"
5016            };
5017            let tree_note = if progressed {
5018                "changed vs base"
5019            } else {
5020                "unchanged vs base"
5021            };
5022            self.state.event(
5023                "fix",
5024                match &fix.failed {
5025                    // Distinct on purpose from "0 addressed, 0 rejected": the
5026                    // fixer's own diff still landed (blocking counts do keep
5027                    // falling round over round), only its adoption report did
5028                    // not come back, so this must never read like every
5029                    // finding was reviewed and declined.
5030                    Some(reason) => {
5031                        format!(
5032                            "round {round}: fixer's adoption report was lost ({reason}); \
5033                             {commit_note}, tree {tree_note}"
5034                        )
5035                    }
5036                    None => format!(
5037                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5038                         {tree_note}{}",
5039                        fix.addressed.len(),
5040                        fix.rejected.len(),
5041                        if continuation.outcome == ContinuationOutcome::Resumed {
5042                            format!(
5043                                " (adoption report recovered after {} continuation(s))",
5044                                continuation.attempts
5045                            )
5046                        } else {
5047                            String::new()
5048                        },
5049                    ),
5050                },
5051            );
5052            round_record.fix = Some(fix);
5053            round_record.progressed = progressed;
5054            self.state.reviews.push(round_record);
5055            self.state.save()?;
5056
5057            // The fixer's own report never came back this round, even after
5058            // `continue_fix_report`'s own budget was spent on it — not an
5059            // ordinary "no report" (dropped stream, quota, plain failure),
5060            // which already reads that way and is left to the existing round
5061            // budget. Stopping here, rather than opening another round, is
5062            // what keeps a next reviewer/fixer wave from ever being
5063            // dispatched onto `winner.worktree` while whatever the seat's
5064            // last call may still have running there is unaccounted for: no
5065            // process liveness check exists (and none is being added — see
5066            // AGENTS.md/this task's own scope), so the only way to honour
5067            // "nothing starts before a valid report returns" is to not start
5068            // anything further on this worktree from this run at all.
5069            if matches!(
5070                continuation.outcome,
5071                ContinuationOutcome::Exhausted
5072                    | ContinuationOutcome::QuotaLost
5073                    | ContinuationOutcome::NoSession
5074            ) {
5075                return self
5076                    .stop_reviewing(
5077                        "the fixer's adoption report never came back, even after resuming its \
5078                         own seat; refusing to start another round against the same worktree \
5079                         while that is unresolved",
5080                        &shell,
5081                        &winner.worktree,
5082                    )
5083                    .await;
5084            }
5085
5086            let streak = self
5087                .state
5088                .reviews
5089                .iter()
5090                .rev()
5091                .take_while(|r| !r.progressed)
5092                .count();
5093            if streak >= STAGNANT_LIMIT {
5094                return self
5095                    .stop_reviewing(
5096                        &format!(
5097                            "the tree has not moved against base for {streak} round(s) in a row"
5098                        ),
5099                        &shell,
5100                        &winner.worktree,
5101                    )
5102                    .await;
5103            }
5104        }
5105        Ok(())
5106    }
5107
5108    /// Decide, from the last recorded round's own verification, whether
5109    /// stopping the review loop is a hand-off or a genuine block.
5110    ///
5111    /// Called once the loop has given up trying — the round budget is spent,
5112    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5113    /// findings still open, never while a round is still clean or the
5114    /// incomplete-panel case handled inline above. Gate and e2e are facts
5115    /// about the tree; a lingering review finding is an opinion, and this
5116    /// workload's own `magi stats` puts reviewer precision low enough
5117    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5118    /// must not by itself stand between a green, verified change and the
5119    /// human who decides what to do with it. A red e2e is not an opinion, so
5120    /// that case still blocks, with the failing command and a tail of its
5121    /// output recorded here rather than left in `run.json` for someone to go
5122    /// find.
5123    ///
5124    /// A round that deferred its own e2e (see [`Config::graph`]'s
5125    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5126    /// only because nothing ran, and treating an empty list as a passing one
5127    /// here is exactly the "deferred painted green" bug this function exists
5128    /// to not have. When the last round's own verification never resolved —
5129    /// deferred on purpose, or a real attempt the shared build cache blocked
5130    /// — this makes (or retries) the real run, on the actual worktree this
5131    /// loop is about to stop touching, before deciding anything. A
5132    /// resource-blocked attempt is likewise never read as either green or
5133    /// red: it is evidence about the machine, not the patch (see
5134    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5135    /// blocked cache leaves this call without deciding rather than guessing
5136    /// — the caller retries on a later reentry.
5137    /// Record, once, that the review loop handed off over a blocking finding
5138    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5139    /// `land` asks the owner even with `land_approval` off. Called from every
5140    /// path that concludes `Gating`; a reentry keeps the first record.
5141    fn record_contested_handoff(&mut self) {
5142        if self.state.contested_handoff.is_some() {
5143            return;
5144        }
5145        let Some(contested) = self
5146            .state
5147            .reviews
5148            .last()
5149            .and_then(ReviewRound::contested_handoff)
5150        else {
5151            return;
5152        };
5153        self.state.event(
5154            "review",
5155            format!(
5156                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5157                 wait for the owner's approval",
5158                contested.findings.len(),
5159                contested.rejecters.len()
5160            ),
5161        );
5162        self.state.contested_handoff = Some(contested);
5163    }
5164
5165    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5166        let round_idx = self.state.reviews.len() - 1;
5167        // A deferred round and a resource-blocked one are the same shape
5168        // here: neither has a real result yet, and both get one more
5169        // attempt. Read off `e2e_status` — the single source for this —
5170        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5171        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5172        // still retries instead of being read as a settled result the
5173        // instant it stops being empty.
5174        let needs_catchup_run = matches!(
5175            self.state.reviews[round_idx].e2e_status(),
5176            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5177        );
5178        if needs_catchup_run {
5179            let round = self.state.reviews[round_idx].round;
5180            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5181            let commands = self.state.config.verify.e2e.clone();
5182            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5183            let cache_dir = self.state.config.cache_dir();
5184            let context = format!(
5185                "round {round}: verification unresolved, catching up before the final decision"
5186            );
5187            let (outcomes, verify_retried) = with_cache_lease(
5188                &mut self.state,
5189                cache_dir.as_deref(),
5190                "e2e",
5191                "e2e",
5192                worktree,
5193                &attempted_head,
5194                timeout,
5195                &context,
5196                |state, budget| {
5197                    let shell = shell.to_vec();
5198                    let commands = commands.clone();
5199                    let context = context.clone();
5200                    async move {
5201                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5202                            .await
5203                    }
5204                },
5205            )
5206            .await;
5207            let last = &mut self.state.reviews[round_idx];
5208            last.e2e = outcomes;
5209            last.verify_retried = verify_retried;
5210            // Always the commit and time this attempt actually targeted,
5211            // whether or not it happens to equal the reviewed `head` and
5212            // whether or not a command finished — see
5213            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5214            // attempt is recorded too, so a later reader sees "attempted
5215            // again at T2" rather than silence.
5216            last.verified_head = Some(attempted_head);
5217            last.verified_at = Some(Timestamp::now());
5218            if verify_inconclusive(&last.e2e) {
5219                // Still not a real result: `e2e_deferred` is left exactly
5220                // as it was, so `needs_catchup_run` above reads
5221                // `ResourceBlocked` (via `e2e_status`, which checks
5222                // `resource_blocked` before `e2e_deferred`) and retries
5223                // again on the next reentry, rather than recording
5224                // contention as a red e2e and blocking the run on it.
5225                self.state.save()?;
5226                return Ok(());
5227            }
5228            last.e2e_deferred = false;
5229        }
5230        let last = &self.state.reviews[round_idx];
5231        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5232
5233        match last.e2e_status() {
5234            E2eStatus::Failed => {
5235                let red: Vec<String> = last
5236                    .e2e
5237                    .iter()
5238                    .filter(|o| !o.ok())
5239                    .map(|o| {
5240                        format!(
5241                            "`{}` -> {:?}\n{}",
5242                            o.command,
5243                            o.code,
5244                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5245                        )
5246                    })
5247                    .collect();
5248                self.state
5249                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5250                self.state.status = RunStatus::Blocked;
5251            }
5252            // `needs_catchup_run` above already retried once this call; if
5253            // it is still blocked, this is magi's own admission it could
5254            // not get a command to run, never a verdict on the patch — the
5255            // run is left exactly where a later reentry can retry again.
5256            E2eStatus::ResourceBlocked => {
5257                self.state.event(
5258                    "review",
5259                    format!(
5260                        "{why}; e2e could not run (shared build cache unavailable); not \
5261                         deciding yet"
5262                    ),
5263                );
5264            }
5265            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5266                self.state.event(
5267                    "review",
5268                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5269                );
5270                self.record_contested_handoff();
5271                self.state.status = RunStatus::Gating;
5272            }
5273        }
5274        self.state.save()?;
5275        Ok(())
5276    }
5277
5278    // ----------------------------------------------------------------- gate
5279
5280    async fn gate(&mut self) -> Result<()> {
5281        // Judged by the review record itself, not by `status`: a solo
5282        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5283        // reentry (see `judge`), and trusting it here is exactly how a run
5284        // that exhausted its review budget got gated and merged a second
5285        // time around. `review_conclusion` recomputes the review loop's own
5286        // verdict from the round records themselves — `Gating` for a clean
5287        // round or a hand-off (see `stop_reviewing`), anything else means the
5288        // loop is still going or genuinely blocked.
5289        // A base the winner could not be replayed onto is a decision, not a
5290        // round: there is no landing tree to gate. Read as its own record for
5291        // the same reason the review verdict is.
5292        if self.state.status == RunStatus::Failed
5293            || self
5294                .state
5295                .base_sync
5296                .as_ref()
5297                .is_some_and(|s| s.conflict.is_some())
5298            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5299                != Some(RunStatus::Gating)
5300        {
5301            return Ok(());
5302        }
5303        if self.state.gate_ran {
5304            // `review_loop` derives its conclusion from the clean review
5305            // record on every reentry and therefore puts a completed run back
5306            // in `Gating`. A recorded gate is a stronger, terminal fact:
5307            // retain its original command output (or lack of any, for a repo
5308            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5309            // doc) and restore `Blocked` on a real failure rather than
5310            // pretending the command is still running or running it a second
5311            // time. `gate_ran == false` remains the only shape — unattempted,
5312            // or a resource-blocked retry — that may still need to execute a
5313            // command.
5314            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5315                self.state.status = RunStatus::Blocked;
5316                self.state.save()?;
5317            }
5318            return Ok(());
5319        }
5320        let Some(winner) = self.state.winner().cloned() else {
5321            return Ok(());
5322        };
5323        self.state.status = RunStatus::Gating;
5324        let mut outcomes = self.run_gate(&winner).await?;
5325        loop {
5326            // A resource-blocked outcome means the gate command never actually
5327            // ran - the shared build cache could not be acquired or confirmed
5328            // fresh in time - which is evidence about the machine, not about
5329            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5330            // Recording it as a red gate would mark a run `Blocked` on nothing
5331            // but contention magi has already logged; leaving `self.state.gate`
5332            // empty and `self.state.gate_ran` false instead keeps the shape
5333            // this function already treats as "still needs to run" (see the
5334            // early-return above), so the next call retries the command
5335            // rather than concluding anything.
5336            if verify_inconclusive(&outcomes) {
5337                self.state.save()?;
5338                return Ok(());
5339            }
5340            if outcomes.iter().all(CommandOutcome::ok) {
5341                break;
5342            }
5343            match self.gate_fix_round(&winner, &outcomes).await? {
5344                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5345                GateFix::Stop => break,
5346                GateFix::Defer => {
5347                    self.state.save()?;
5348                    return Ok(());
5349                }
5350            }
5351        }
5352        let passed = outcomes.iter().all(CommandOutcome::ok);
5353        self.state.gate = outcomes;
5354        self.state.gate_ran = true;
5355        if !passed {
5356            self.state.status = RunStatus::Blocked;
5357            let spent = self.state.gate_fixes.len();
5358            self.state.event(
5359                "gate",
5360                if spent == 0 {
5361                    "gate failed; not merging".to_owned()
5362                } else {
5363                    format!("gate failed after {spent} gate-fix round(s); not merging")
5364                },
5365            );
5366        }
5367        self.state.save()?;
5368        Ok(())
5369    }
5370
5371    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
5372    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
5373    /// after review is clean and never on a candidate awaiting judging.
5374    ///
5375    /// Never fails the run: a non-zero exit or timeout is a warning and a
5376    /// recorded outcome, and the gate remains the single arbiter. Nothing
5377    /// configured means nothing happens - no event, no commit. `commit_all`
5378    /// commits any leftover change under the neutral identity and returns
5379    /// `false` when the tree is clean, so no empty commit is ever made.
5380    async fn run_pre_gate(&mut self, winner: &Candidate) {
5381        let commands = self.state.config.verify.pre_gate.clone();
5382        if commands.is_empty() {
5383            return;
5384        }
5385        let shell = self.state.config.shell();
5386        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5387        let (outcomes, _) = run_commands(
5388            &mut self.state,
5389            "pre_gate",
5390            "pre_gate",
5391            0,
5392            &shell,
5393            &commands,
5394            &winner.worktree,
5395            timeout,
5396        )
5397        .await;
5398        for o in &outcomes {
5399            if !o.ok() {
5400                tracing::warn!(
5401                    "pre_gate `{}` failed ({:?}); the gate decides",
5402                    o.command,
5403                    o.code
5404                );
5405            }
5406            self.state.event(
5407                "pre_gate",
5408                format!(
5409                    "`{}` -> {}",
5410                    o.command,
5411                    if o.ok() {
5412                        "pass".to_owned()
5413                    } else {
5414                        format!(
5415                            "FAIL ({:?})\n{}",
5416                            o.code,
5417                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5418                        )
5419                    }
5420                ),
5421            );
5422        }
5423        self.state.pre_gate = outcomes;
5424        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5425            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5426                Ok(head) => {
5427                    self.state
5428                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
5429                    self.state.pre_gate_commit = Some(head);
5430                }
5431                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5432            },
5433            Ok(false) => {}
5434            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5435        }
5436        if let Err(e) = self.state.save() {
5437            tracing::warn!("could not persist the pre_gate record: {e:#}");
5438        }
5439    }
5440
5441    /// Run `verify.gate` once against the winner's current tree, logging one
5442    /// event per command. Empty when nothing is configured.
5443    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5444        self.run_pre_gate(winner).await;
5445        let shell = self.state.config.shell();
5446        let gate_commands = self.state.config.verify.gate.clone();
5447        // Zero commands has nothing to run and nothing that could touch the
5448        // shared build cache, so it never needs a lease: `Config::cache_dir`
5449        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
5450        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
5451        // otherwise queue behind an unrelated run's lease and come back
5452        // resource-blocked - `gate_ran` would stay false on nothing but
5453        // cache contention, for a step that had nothing to check in the
5454        // first place.
5455        let outcomes = if gate_commands.is_empty() {
5456            Vec::new()
5457        } else {
5458            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5459            let cache_dir = self.state.config.cache_dir();
5460            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5461            let (outcomes, _) = with_cache_lease(
5462                &mut self.state,
5463                cache_dir.as_deref(),
5464                "gate",
5465                "gate",
5466                &winner.worktree,
5467                &head,
5468                timeout,
5469                "final gate",
5470                |state, budget| {
5471                    let shell = shell.clone();
5472                    let gate_commands = gate_commands.clone();
5473                    let worktree = winner.worktree.clone();
5474                    async move {
5475                        let (outcomes, timed_out_pids) = run_commands(
5476                            state,
5477                            "gate",
5478                            "gate",
5479                            0,
5480                            &shell,
5481                            &gate_commands,
5482                            &worktree,
5483                            budget,
5484                        )
5485                        .await;
5486                        (outcomes, false, timed_out_pids)
5487                    }
5488                },
5489            )
5490            .await;
5491            outcomes
5492        };
5493        if outcomes.is_empty() {
5494            // Nothing configured to check — distinct from every other
5495            // silence in this run's event log, since an empty `gate` alone
5496            // no longer says whether the gate ran at all (see
5497            // `RunState::gate_ran`'s own doc).
5498            self.state.event(
5499                "gate",
5500                "no gate commands configured; nothing to check, passing",
5501            );
5502        }
5503        for o in &outcomes {
5504            self.state.event(
5505                "gate",
5506                format!(
5507                    "`{}` -> {}",
5508                    o.command,
5509                    if o.ok() {
5510                        "pass".to_owned()
5511                    } else {
5512                        format!(
5513                            "FAIL ({:?})\n{}",
5514                            o.code,
5515                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5516                        )
5517                    }
5518                ),
5519            );
5520        }
5521        Ok(outcomes)
5522    }
5523
5524    /// One bounded fix round for a failing gate.
5525    ///
5526    /// The fixer is told the failure came from the gate itself, not from a
5527    /// reviewer, and is shown the failed commands, their exit codes and a tail
5528    /// of their output - whatever `[verify].gate` holds, nothing here knows
5529    /// what those commands run. Only a normal non-zero exit that printed
5530    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
5531    /// command or a full disk says nothing about the code, and a fixer sent
5532    /// after it can only appease the machine. The round is judged by what git
5533    /// says moved, never by the fixer's own report, and `verify.e2e` runs
5534    /// again before the gate does, so a fix cannot trade a green gate for a
5535    /// red e2e unnoticed.
5536    async fn gate_fix_round(
5537        &mut self,
5538        winner: &Candidate,
5539        outcomes: &[CommandOutcome],
5540    ) -> Result<GateFix> {
5541        let cap = self.state.config.graph.gate_fix_rounds;
5542        let spent = self.state.gate_fixes.len();
5543        if spent >= cap {
5544            if cap > 0 {
5545                self.state.event(
5546                    "gate",
5547                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
5548                );
5549            }
5550            return Ok(GateFix::Stop);
5551        }
5552        if !gate_fixable(outcomes) {
5553            self.state.event(
5554                "gate",
5555                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5556                 command or similar); not spending a fix round on it",
5557            );
5558            return Ok(GateFix::Stop);
5559        }
5560        let min_free = self.state.config.disk.min_free_bytes;
5561        if min_free > 0 {
5562            match crate::disk::free_bytes(&winner.worktree) {
5563                Ok(free) if crate::disk::enough_space(free, min_free) => {}
5564                Ok(free) => {
5565                    self.state.event(
5566                        "gate",
5567                        format!(
5568                            "only {free} bytes free ({min_free} required by `[disk] \
5569                             min_free_bytes`); not spending a fix round on a failure the disk \
5570                             may explain"
5571                        ),
5572                    );
5573                    return Ok(GateFix::Stop);
5574                }
5575                Err(e) => {
5576                    self.state.event(
5577                        "gate",
5578                        format!("free disk space could not be measured ({e:#}); no fix round"),
5579                    );
5580                    return Ok(GateFix::Stop);
5581                }
5582            }
5583        }
5584
5585        let attempt = spent + 1;
5586        let run_id = self.state.id.clone();
5587        let prompts = self.state.config.prompts.clone();
5588        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5589        let base = self.landing_base();
5590        let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5591        let seat = self.seat(&fix_seat_key, &fix_spec.id);
5592        let job = SeatJob {
5593            prompt: prompt::gate_fix(
5594                &self.state.instruction,
5595                &failed,
5596                attempt,
5597                cap,
5598                &self.state.config.graph.language,
5599            ),
5600            spec: fix_spec,
5601            seat,
5602            cwd: winner.worktree.clone(),
5603            timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5604            allow_write: true,
5605            sessions: self.state.config.graph.sessions,
5606            artifacts: agent::artifacts_dir(&self.state.dir()),
5607            stem: format!("gate-fix-{attempt}"),
5608        };
5609        self.state.event(
5610            "gate",
5611            format!("gate failed; gate-fix round {attempt} of {cap}"),
5612        );
5613        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5614        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5615        let cache = self.state.config.cache_dir();
5616        let ctx = WaveCtx {
5617            run: &run_id,
5618            node: "gate-fix",
5619            prompts: &prompts,
5620            cache: cache.as_deref(),
5621            round: None,
5622        };
5623        let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5624        let mut record = GateFixRecord {
5625            agent: seat.agent.clone(),
5626            failed,
5627            notes: String::new(),
5628            committed: false,
5629            error: None,
5630        };
5631        match out {
5632            AgentOutcome::Ok(o) => {
5633                // A missing report is not a failed fix: the round is judged
5634                // by the tree below, and the report only carries prose.
5635                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5636                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5637                }
5638            }
5639            AgentOutcome::Dropped(_) => {
5640                record.error = Some("the CLI dropped the stream".to_owned());
5641            }
5642            AgentOutcome::Quota(o) => {
5643                self.state.quota.push(QuotaLoss {
5644                    seat: seat.key.clone(),
5645                    node: "gate-fix".to_owned(),
5646                    at: Timestamp::now(),
5647                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5648                });
5649                record.error = Some("rate limited (quota); fixer could not run".to_owned());
5650            }
5651            AgentOutcome::Failed(e) => record.error = Some(e),
5652        }
5653        self.state.seats.insert(seat.key.clone(), seat);
5654        if let Ok(r) = git::rescue_commit(
5655            &winner.worktree,
5656            &format!("magi: gate fix {attempt} (uncommitted work)"),
5657        )
5658        .await
5659        {
5660            self.state.note_withheld("gate-fix", &r.withheld);
5661        }
5662        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5663        record.committed = after != before;
5664        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5665        let note = record.error.clone();
5666        self.state.gate_fixes.push(record);
5667        self.state.save()?;
5668        if !changed {
5669            self.state.event(
5670                "gate",
5671                match note {
5672                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5673                    None => format!("gate-fix round {attempt}: the tree did not change"),
5674                },
5675            );
5676            return Ok(GateFix::Stop);
5677        }
5678        self.state.event(
5679            "gate",
5680            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5681        );
5682
5683        let commands = self.state.config.verify.e2e.clone();
5684        if !commands.is_empty() {
5685            let shell = self.state.config.shell();
5686            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5687            let cache_dir = self.state.config.cache_dir();
5688            let context = format!("gate-fix round {attempt}");
5689            let (e2e, _) = with_cache_lease(
5690                &mut self.state,
5691                cache_dir.as_deref(),
5692                "e2e",
5693                "e2e",
5694                &winner.worktree,
5695                &after,
5696                timeout,
5697                &context,
5698                |state, budget| {
5699                    let shell = shell.clone();
5700                    let commands = commands.clone();
5701                    let context = context.clone();
5702                    let worktree = winner.worktree.clone();
5703                    async move {
5704                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5705                            .await
5706                    }
5707                },
5708            )
5709            .await;
5710            if verify_inconclusive(&e2e) {
5711                return Ok(GateFix::Defer);
5712            }
5713            if e2e.iter().any(|o| !o.ok()) {
5714                self.state.event(
5715                    "gate",
5716                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5717                );
5718                return Ok(GateFix::Stop);
5719            }
5720        }
5721        Ok(GateFix::Retry)
5722    }
5723
5724    // ---------------------------------------------------------------- merge
5725
5726    async fn merge(&mut self) -> Result<()> {
5727        // Same reasoning as `gate`: ask the review and gate records directly
5728        // rather than `status`, which a solo-candidate `judge`/`deliberate`
5729        // skip can rewrite on reentry to something that no longer says
5730        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
5731        // so a hand-off (open findings, green verification) reaches merge
5732        // exactly like a genuinely clean round does.
5733        //
5734        // A run resumed mid-`land` never reaches here at all: `execute`
5735        // recognises `RunStatus::Landing` before it even calls `prep`, and
5736        // routes straight to `run_land` instead. That has to happen a level
5737        // up from this function, not with a check in here, because
5738        // `review_loop`'s own status recomputation (see its doc) runs
5739        // *before* `merge` on every reentry and would otherwise overwrite
5740        // the `Landing` marker with `Gating` before this node ever saw it.
5741        if self
5742            .state
5743            .base_sync
5744            .as_ref()
5745            .is_some_and(|s| s.conflict.is_some())
5746            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5747                != Some(RunStatus::Gating)
5748            // `gate_ran == false` is not "passed" - `gate` leaves it false
5749            // both before it has ever run and when its last attempt was
5750            // resource-blocked (see `Runner::gate`'s own doc), and neither is
5751            // permission to merge on nothing but the review record. Only a
5752            // gate that actually ran - zero commands configured and
5753            // vacuously passed, or one or more that all exited 0 - may
5754            // proceed; `RunState::gate_status` is the single place that
5755            // reading is computed.
5756            || !self.state.gate_status().ok()
5757        {
5758            return Ok(());
5759        }
5760        // This node's own record, not `status`: `status == Ready` is not
5761        // unique to the harmless `MergeMode::None` path this line was
5762        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
5763        // run's PR was closed without merging — and on that run `mode` is
5764        // still `Pr`, so a reentry that fell through here would push and
5765        // open a second pull request. `self.state.merge` is set exactly once
5766        // this node (or `land`) has already produced a verdict, under every
5767        // mode, which is what "already done" actually means here.
5768        if self.state.merge.is_some() {
5769            return Ok(());
5770        }
5771        let Some(winner) = self.state.winner().cloned() else {
5772            return Ok(());
5773        };
5774        let repo = self.state.repo.clone();
5775        let base = self.state.base_branch.clone();
5776        let mode = self.state.config.merge.mode;
5777        let style = self.state.config.merge.style;
5778        let facts = if is_review_run(&self.state) {
5779            branch_facts(&repo, &self.state.base_commit, &winner.branch).await
5780        } else {
5781            None
5782        };
5783        let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5784        let message = pr.commit_message();
5785
5786        let outcome = match mode {
5787            MergeMode::None => MergeOutcome {
5788                mode,
5789                ok: true,
5790                detail: manual_merge_command(style, &repo, &winner.branch, &message),
5791                empty: false,
5792            },
5793            MergeMode::Pr | MergeMode::Local
5794                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5795            {
5796                MergeOutcome {
5797                    mode,
5798                    ok: false,
5799                    detail: empty_candidate_detail(&self.state, &base),
5800                    empty: true,
5801                }
5802            }
5803            MergeMode::Local => {
5804                let on = git::current_branch(&repo).await?;
5805                if on.as_deref() != Some(base.as_str()) {
5806                    MergeOutcome {
5807                        mode,
5808                        ok: false,
5809                        detail: format!(
5810                            "{} has {} checked out, not the base branch {base}",
5811                            repo.display(),
5812                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
5813                        ),
5814                        empty: false,
5815                    }
5816                } else if !git::is_clean(&repo).await? {
5817                    MergeOutcome {
5818                        mode,
5819                        ok: false,
5820                        detail: format!("{} is dirty; refusing to merge", repo.display()),
5821                        empty: false,
5822                    }
5823                } else {
5824                    let out = match style {
5825                        MergeStyle::Merge => {
5826                            git::merge_no_ff(&repo, &winner.branch, &message).await?
5827                        }
5828                        MergeStyle::Squash => {
5829                            git::merge_squash(&repo, &winner.branch, &message).await?
5830                        }
5831                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
5832                    };
5833                    MergeOutcome {
5834                        mode,
5835                        ok: out.ok(),
5836                        detail: if out.ok() { out.stdout } else { out.stderr },
5837                        empty: false,
5838                    }
5839                }
5840            }
5841            MergeMode::Pr => {
5842                let remote = self.state.config.merge.remote.clone();
5843                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
5844                if !pushed.ok() {
5845                    MergeOutcome {
5846                        mode,
5847                        ok: false,
5848                        detail: pushed.stderr,
5849                        empty: false,
5850                    }
5851                } else {
5852                    // A retry or resume of a run whose branch already has an
5853                    // open pull request adopts it rather than failing on a
5854                    // duplicate. Only this winner branch into this base:
5855                    // `branch_for` derives the name from the run id, so a
5856                    // different run's pull request never matches.
5857                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
5858                    let out = match pr_merge_plan(found) {
5859                        PrPlan::Create => {
5860                            gh_pr_create(
5861                                &winner.worktree,
5862                                &base,
5863                                &winner.branch,
5864                                &pr.title,
5865                                &pr.body,
5866                            )
5867                            .await
5868                        }
5869                        PrPlan::Adopt { url, title } => {
5870                            self.state
5871                                .event("merge", format!("Pr: adopted open pull request {url}"));
5872                            if title != pr.title
5873                                && let Err(e) =
5874                                    land::set_pr_title(&winner.worktree, &url, &pr.title).await
5875                            {
5876                                tracing::warn!("could not refresh title of {url}: {e:#}");
5877                                self.state
5878                                    .event("merge", format!("Pr: title refresh failed: {e:#}"));
5879                            }
5880                            Ok(url)
5881                        }
5882                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
5883                    };
5884                    match out {
5885                        Ok(url) => MergeOutcome {
5886                            mode,
5887                            ok: true,
5888                            detail: url,
5889                            empty: false,
5890                        },
5891                        Err(e) => MergeOutcome {
5892                            mode,
5893                            ok: false,
5894                            detail: e.to_string(),
5895                            empty: false,
5896                        },
5897                    }
5898                }
5899            }
5900        };
5901
5902        self.state.status = match (mode, outcome.ok) {
5903            (MergeMode::None, _) => RunStatus::Ready,
5904            (_, true) => RunStatus::Merged,
5905            (_, false) => RunStatus::Blocked,
5906        };
5907        self.state.event(
5908            "merge",
5909            format!(
5910                "{:?}: {}",
5911                mode,
5912                outcome.detail.lines().next().unwrap_or("")
5913            ),
5914        );
5915        self.state.merge = Some(outcome);
5916        self.state.save()?;
5917
5918        // The PR is open and the run would historically stop here, leaving the
5919        // operator to watch checks, feed review comments back to a fixer, and
5920        // merge. That was done by hand six times in one session before this
5921        // existed. Opt-in, because merging is the one irreversible thing magi
5922        // can do to a repository.
5923        if self.state.config.graph.land
5924            && mode == MergeMode::Pr
5925            && self.state.status == RunStatus::Merged
5926        {
5927            self.run_land().await?;
5928        }
5929        // `run_land` may have left `status` at `Landing` - still waiting on
5930        // CI or the owner's approval, not actually settled - so this has to
5931        // read whatever `status` ended up as here, not the `Merged` this
5932        // function set a few lines up.
5933        self.settle_questions();
5934        Ok(())
5935    }
5936
5937    /// Enter `land`.
5938    ///
5939    /// Shared between a fresh run's first pass through [`Runner::merge`] and
5940    /// a resumed run's re-entry. `land::land` itself is what serialises the
5941    /// two git-mutating moments inside the loop — the rebase push and
5942    /// `gh pr merge` — per repository (see its own doc); nothing here needs
5943    /// to hold a lock across the whole call, and doing so would serialise
5944    /// this run's CI wait against a *different* run's land-approval resume
5945    /// in the same repository, which is exactly the "must not wait on
5946    /// another task" property the daemon's slot-freeing exists to give.
5947    async fn run_land(&mut self) -> Result<()> {
5948        let url = self
5949            .state
5950            .merge
5951            .as_ref()
5952            .map(|m| m.detail.clone())
5953            .unwrap_or_default();
5954        let url = url.lines().next().unwrap_or("").trim().to_owned();
5955        if !url.starts_with("http") {
5956            return Ok(());
5957        }
5958        // A land failure is not a lost run: the work is on a branch and the
5959        // pull request is open, which is exactly where a human takes over.
5960        match land::land(&mut self.state, &url).await {
5961            Ok(pr) if self.state.parked => {
5962                // `land` already saved the parked marker; nothing here
5963                // overrides `status` back to a terminal value while an
5964                // approval is still outstanding.
5965                let _ = pr;
5966            }
5967            Ok(pr) => {
5968                self.state.status = match pr.state {
5969                    land::PrLifecycle::Merged => RunStatus::Merged,
5970                    _ => RunStatus::Blocked,
5971                };
5972                // Downstream of a confirmed merge only - see
5973                // `bump::should_release_bump`'s own doc for why this one
5974                // check covers all three of `land`'s success paths.
5975                // Best-effort: the run already landed, so a failure here
5976                // (the decision call, `gh`, `cargo`) is recorded and never
5977                // turns a landed run into a failed one.
5978                if bump::should_release_bump(self.state.status)
5979                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
5980                {
5981                    // The event is the run's own record. Not-eligible cases
5982                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
5983                    // `Err` is a bump that was tried and failed:
5984                    // `after_merge` itself raises the operator notice for
5985                    // that, whether or not a release PR exists yet.
5986                    self.state
5987                        .event("bump", format!("release bump skipped: {e:#}"));
5988                }
5989                // Independent of the bump, and best-effort in the same way:
5990                // findings the merge left open become follow-up tasks.
5991                if self.state.status == RunStatus::Merged {
5992                    crate::followup::after_merge(&mut self.state, &pr.url).await;
5993                }
5994                self.state.save()?;
5995            }
5996            Err(e) => {
5997                self.state.status = RunStatus::Blocked;
5998                self.state.event("land", format!("gave up: {e}"));
5999                self.state.save()?;
6000            }
6001        }
6002        Ok(())
6003    }
6004
6005    // -------------------------------------------------------------- helpers
6006
6007    /// Fetch or create a seat, keeping its conversation across nodes.
6008    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6009        if let Some(existing) = self.state.seats.get(key)
6010            && existing.agent == agent
6011        {
6012            return existing.clone();
6013        }
6014        let fresh = SeatState::new(key, agent, self.state.seed);
6015        self.state.seats.insert(key.to_owned(), fresh.clone());
6016        fresh
6017    }
6018
6019    /// The agent now holding seat `key`: `spec`, unless a handover moved the
6020    /// seat to another roster agent, in which case that agent. Nodes that
6021    /// continue a seat's conversation (deliberation, the votes, a reviewer's
6022    /// reconsideration) must keep talking to whoever answered it, not slip
6023    /// back to the agent that failed it.
6024    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6025        match self.state.seats.get(key) {
6026            Some(s) if s.agent != spec.id => {
6027                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6028            }
6029            _ => spec,
6030        }
6031    }
6032
6033    /// A candidate rendered for judging, with the leak policy applied.
6034    fn view(&self, c: &Candidate) -> CandidateView {
6035        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6036            .unwrap_or_default();
6037        let (patch, _) = blind::sanitize_patch(
6038            &format!("candidate {} patch", c.label),
6039            &raw,
6040            &self.state.config.blind,
6041        );
6042        CandidateView {
6043            label: c.label,
6044            branch: c.branch.clone(),
6045            summary: c.summary.clone(),
6046            stat: c.stat.clone(),
6047            patch,
6048        }
6049    }
6050
6051    /// The full candidate set as prompt text, for seats with no live session.
6052    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6053        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6054        prompt::judge(
6055            "(see above)",
6056            &views,
6057            self.roles.judges.len(),
6058            base_short,
6059            "en",
6060        )
6061    }
6062
6063    /// Anonymised transcript for judge `self_idx`.
6064    ///
6065    /// The initial rankings are always the opening statements. Seeding them
6066    /// only when no turn had been taken yet meant every judge after the first
6067    /// argued against a single voice instead of against the actual split — the
6068    /// disagreement is the information, so it is always on the table.
6069    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6070        let mut turns = Vec::new();
6071        for j in &self.state.judgements {
6072            if j.ranking.is_empty() {
6073                continue;
6074            }
6075            let reasons = j
6076                .reasons
6077                .iter()
6078                .map(|(k, v)| format!("- {k}: {v}"))
6079                .collect::<Vec<_>>()
6080                .join("\n");
6081            turns.push(Turn {
6082                who: format!("Judge {} (opening ranking)", j.judge),
6083                is_self: j.judge == self_idx + 1,
6084                body: format!(
6085                    "Ranked {}{}{reasons}",
6086                    j.ranking.iter().collect::<String>(),
6087                    if reasons.is_empty() {
6088                        ""
6089                    } else {
6090                        ", because:\n"
6091                    }
6092                ),
6093            });
6094        }
6095        for t in self
6096            .state
6097            .deliberation
6098            .iter()
6099            .flat_map(|r| r.turns.iter())
6100            .chain(current)
6101        {
6102            turns.push(Turn {
6103                who: format!("Judge {}", t.judge),
6104                is_self: t.judge == self_idx + 1,
6105                body: t.body.clone(),
6106            });
6107        }
6108        turns
6109    }
6110}
6111
6112/// Does this seat still hold the context a follow-up prompt would rely on?
6113fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6114    agent::has_session(spec.kind, seat, sessions)
6115}
6116
6117/// The next entry in `roster` after `start`, never wrapping back to the
6118/// front, whose id is not in `tried` yet.
6119///
6120/// Starts one past `start` rather than at the front of `roster`: `start` is
6121/// the seat's own original position, and a seat whose candidate slot already
6122/// sits on the roster's second entry must fall through to the third next, not
6123/// restart at the first — which is very likely a different candidate's own
6124/// agent already. Never wraps back past `start`, for the same reason: an
6125/// entry earlier in the roster than the seat's own position is almost
6126/// certainly some *other* candidate slot's own agent, and once the tail of
6127/// the roster is exhausted there are no more untried agents for *this* seat
6128/// to fall through to — the caller's fallback chain ends there, exactly as
6129/// "no further untried agents remain in the list for that seat" asks for.
6130///
6131/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
6132/// the same id twice (an operator's `roles.implementers` typo, or a
6133/// `[[agents]]` list reused across roles) must not let
6134/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
6135/// over `roster` either finds an untried id or runs out, so this always
6136/// terminates regardless of duplicates.
6137fn next_untried_in_roster<'a>(
6138    roster: &'a [AgentSpec],
6139    start: usize,
6140    tried: &BTreeSet<String>,
6141) -> Option<&'a AgentSpec> {
6142    roster
6143        .get(start + 1..)?
6144        .iter()
6145        .find(|s| !tried.contains(&s.id))
6146}
6147
6148/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
6149/// seed so a CLI that mints its session id up front (`--session-id`) never
6150/// reuses the uuid the previous agent already opened under the same seat key.
6151fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6152    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6153}
6154
6155/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
6156/// so the classifier below and the code that builds the message cannot drift.
6157const TIMED_OUT: &str = "timed out";
6158
6159/// What kind of failure ended an agent's turn on a seat, for deciding whether
6160/// the seat is worth handing to the next roster agent.
6161#[derive(Debug, Clone, PartialEq, Eq)]
6162enum FailClass {
6163    Quota,
6164    Timeout,
6165    /// Any other failure, with the message's shape ([`failure_signature`]).
6166    Other(String),
6167}
6168
6169impl FailClass {
6170    /// `None` for an answer; otherwise how the turn failed.
6171    fn of(out: &AgentOutcome) -> Option<Self> {
6172        match out {
6173            AgentOutcome::Ok(_) => None,
6174            AgentOutcome::Quota(_) => Some(Self::Quota),
6175            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6176            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6177            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6178        }
6179    }
6180
6181    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
6182    fn stem_word(&self) -> &'static str {
6183        match self {
6184            Self::Quota => "quota",
6185            _ => "handover",
6186        }
6187    }
6188}
6189
6190/// The message's first line with its variable parts removed — digit runs and
6191/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
6192/// as one kind of failure.
6193fn failure_signature(msg: &str) -> String {
6194    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6195    let mut out = Vec::new();
6196    for word in line.split_whitespace() {
6197        if word.contains('/') || word.contains('\\') {
6198            out.push("<path>".to_owned());
6199            continue;
6200        }
6201        let mut w = String::new();
6202        let mut in_digits = false;
6203        for c in word.chars() {
6204            if c.is_ascii_digit() {
6205                if !in_digits {
6206                    w.push('#');
6207                }
6208                in_digits = true;
6209            } else {
6210                in_digits = false;
6211                w.push(c);
6212            }
6213        }
6214        out.push(w);
6215    }
6216    out.join(" ").chars().take(120).collect()
6217}
6218
6219/// Whether a seat that just failed with `cur` may go to the next roster agent.
6220/// A quota or a timeout always may. Any other failure may not when the agent
6221/// before it failed the same way: an error the prompt causes would otherwise
6222/// walk the whole roster. `prev` is the class of the immediately preceding
6223/// agent's failure, so a quota or timeout in between breaks the run of
6224/// identical failures by itself.
6225fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6226    match cur {
6227        FailClass::Quota | FailClass::Timeout => true,
6228        FailClass::Other(_) => prev != Some(cur),
6229    }
6230}
6231
6232/// A short human reason for a failed outcome, for the handover record.
6233fn fail_reason(out: &AgentOutcome) -> String {
6234    match out {
6235        AgentOutcome::Ok(_) => String::new(),
6236        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6237        AgentOutcome::Dropped(o) => format!(
6238            "the CLI dropped the stream ({})",
6239            o.dropped
6240                .as_ref()
6241                .map(|d| d.why.as_str())
6242                .unwrap_or("it ended without delivering its answer")
6243        ),
6244        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6245    }
6246}
6247
6248/// Note one handover in the run: the structured record and, in the timeline,
6249/// the sentence a person reads. A quota keeps the wording it always had.
6250fn record_handover(
6251    state: &mut RunState,
6252    node: &str,
6253    seat: &str,
6254    from: &str,
6255    to: &str,
6256    class: &FailClass,
6257    reason: &str,
6258) {
6259    let message = if *class == FailClass::Quota {
6260        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6261    } else {
6262        format!("{seat}: handed over {from} -> {to} ({reason})")
6263    };
6264    state.event(node, message);
6265    state.handovers.push(Handover {
6266        at: Timestamp::now(),
6267        node: node.to_owned(),
6268        seat: seat.to_owned(),
6269        from: from.to_owned(),
6270        to: to.to_owned(),
6271        reason: reason.to_owned(),
6272    });
6273}
6274
6275/// Did this reply report running a command whose own CLI never confirmed an
6276/// exit status?
6277///
6278/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
6279/// command *finished* (see that type's own doc), so this can only be `true`
6280/// for a command whose completion event carried no readable exit code — not
6281/// for one that simply is not mentioned at all. That is the one signal this
6282/// crate can read, from the same record `state.jobs` renders, about a reply
6283/// standing next to work its own CLI cannot vouch for finishing; it is
6284/// deliberately not a check on the exit code's *value* (a fixer legitimately
6285/// runs a command that fails mid-iteration before it succeeds) and not a
6286/// guess at a command still running in the background (which emits no event
6287/// at all, and so leaves no evidence here to find).
6288fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6289    commands.iter().any(|c| c.exit_code.is_none())
6290}
6291
6292/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
6293/// trusted as a verified no-op — the adoption guard's own text-level half.
6294///
6295/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
6296/// timed out): a marker only earns the benefit of the doubt from a turn the
6297/// CLI itself vouches for finishing properly, the same house style
6298/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
6299/// *fix* report to for `commands`. A candidate that timed out, exited
6300/// non-zero, or left a command unconfirmed is read as the ordinary loss it
6301/// is, whatever prose it wrote — this returns `None` before it ever looks at
6302/// `text`. The remaining guards (the tree really is empty, the evidence is
6303/// non-empty) are the caller's: this only reads what the reply *claimed*.
6304fn verified_noop_claim(
6305    usable: bool,
6306    commands: &[agent::CommandEvidence],
6307    text: &str,
6308) -> Option<String> {
6309    (usable && !has_unconfirmed_command(commands))
6310        .then(|| verdict::verified_noop(text))
6311        .flatten()
6312}
6313
6314fn short(commit: &str) -> String {
6315    commit.chars().take(7).collect()
6316}
6317
6318fn make_executable(path: &Path) -> Result<()> {
6319    #[cfg(unix)]
6320    {
6321        use std::os::unix::fs::PermissionsExt as _;
6322        let mut perms = std::fs::metadata(path)?.permissions();
6323        perms.set_mode(0o755);
6324        std::fs::set_permissions(path, perms)?;
6325    }
6326    #[cfg(not(unix))]
6327    {
6328        let _ = path;
6329    }
6330    Ok(())
6331}
6332
6333/// What every seat in one batch shares: where the answers are attributed, the
6334/// prompt overlay they inherit, and the build cache they are told to use.
6335///
6336/// A struct rather than four more parameters: `wave` also needs the run's
6337/// state (to record who is answering right now) and the attempt number, and
6338/// eight positional arguments is both unreadable and a clippy error.
6339struct WaveCtx<'a> {
6340    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
6341    /// paid for it.
6342    run: &'a str,
6343    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
6344    node: &'a str,
6345    prompts: &'a Prompts,
6346    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
6347    cache: Option<&'a Path>,
6348    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
6349    /// `JobRecord::round`. `None` for every other node.
6350    round: Option<usize>,
6351}
6352
6353/// Run one job, honouring the parallelism budget.
6354async fn run_one(
6355    job: SeatJob,
6356    sem: Arc<Semaphore>,
6357    ctx: &WaveCtx<'_>,
6358    state: &mut RunState,
6359    attempt: usize,
6360) -> (SeatState, AgentOutcome) {
6361    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6362        .await
6363        .pop()
6364        .expect("one job in, one result out");
6365    (seat, out)
6366}
6367
6368/// Run every job concurrently, capped by the semaphore, preserving order.
6369///
6370/// Every seat in the batch is recorded into [`RunState::active`] before the
6371/// wave starts and cleared as each answer lands, so the run's own record says
6372/// who is still being waited on rather than only who finished.
6373async fn wave(
6374    jobs: Vec<SeatJob>,
6375    sem: Arc<Semaphore>,
6376    ctx: &WaveCtx<'_>,
6377    state: &mut RunState,
6378    attempt: usize,
6379) -> Vec<(usize, SeatState, AgentOutcome)> {
6380    let WaveCtx {
6381        run,
6382        node,
6383        prompts,
6384        cache,
6385        round,
6386    } = *ctx;
6387    for job in &jobs {
6388        state.seat_started(node, &job.seat.key, job.timeout, attempt);
6389    }
6390    if let Err(e) = state.save() {
6391        // A failed persist of "who is answering right now" must not abort the
6392        // wave: the seats are already being asked, and the alternative is
6393        // losing the answers to save a status line nobody may even be
6394        // watching.
6395        tracing::warn!("could not persist in-progress seats: {e:#}");
6396    }
6397    // Hold the shared build cache's lease for the whole batch, not per job:
6398    // several candidates (an implement wave) or a fixer legitimately share
6399    // one cache concurrently within this run, and that stays untouched — a
6400    // single lease taken once for the whole wave and released once it is
6401    // done is what stops a *different* borrower (another run's own wave, its
6402    // e2e/gate, a human's `magi review`) from interleaving a build into the
6403    // same directory while this one is in flight. Best-effort, not
6404    // all-or-nothing: a wave that cannot get the lease within its own
6405    // longest job's budget still runs — an hour of paid implementer calls is
6406    // not thrown away over cache contention — but every write-allowed seat
6407    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
6408    // below), the same fallback a read-only seat always gets, rather than
6409    // building into a directory this run was never granted. The identity
6410    // record is still invalidated below either way, so the next tracked
6411    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
6412    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6413    let wait_started = Instant::now();
6414    let cache_guard = if let Some(cache_dir) = cache {
6415        if jobs_had_a_writer {
6416            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6417            let budget = jobs
6418                .iter()
6419                .map(|j| j.timeout)
6420                .max()
6421                .unwrap_or(Duration::from_secs(60));
6422            acquire_cache_lease(state, cache_dir, &owner, budget, node)
6423                .await
6424                .ok()
6425        } else {
6426            None
6427        }
6428    } else {
6429        None
6430    };
6431    // Carved out of each job's own budget, not added on top of it: a seat
6432    // that waited behind the lease must not also get its full timeout
6433    // afterward, or a run contended on the cache could double the time it
6434    // spends per wave. `saturating_sub` floors at zero rather than
6435    // wrapping - a job whose whole budget was spent waiting starts with
6436    // none left, which is the honest number, not a free minimum.
6437    let waited_for_lease = wait_started.elapsed();
6438    let mut set = tokio::task::JoinSet::new();
6439    let overlay = prompts.overlay(node);
6440    for (i, mut job) in jobs.into_iter().enumerate() {
6441        job.timeout = job.timeout.saturating_sub(waited_for_lease);
6442        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6443        if cache.is_some() {
6444            job.prompt.push('\n');
6445            job.prompt
6446                .push_str(&prompt::build_cache_note(node, job.allow_write));
6447        }
6448        let sem = Arc::clone(&sem);
6449        let run = run.to_owned();
6450        let node = node.to_owned();
6451        // Only implementers were told about the task's attachments, so only
6452        // their seats get the directory widened for reading.
6453        let attachments = if node == "implement" {
6454            state.attachments.clone()
6455        } else {
6456            Vec::new()
6457        };
6458        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
6459        // `prompt::build_cache_note`'s doc for why setting it anyway is
6460        // exactly how a sandboxed reviewer's write refusal got reported as a
6461        // defect in the patch, not a property of its own seat. And a
6462        // write-allowed one is handed it only when the lease above was
6463        // actually acquired: a wave that could not get it (`cache_guard` is
6464        // `None`, see its own comment) must not send seats to build into a
6465        // directory this run does not hold - that is the exact concurrent,
6466        // unmanaged-write race this module exists to prevent, not something
6467        // "proceeding anyway" is allowed to reintroduce.
6468        let cache = cache
6469            .filter(|_| job.allow_write && cache_guard.is_some())
6470            .map(Path::to_path_buf);
6471        set.spawn(async move {
6472            let _permit = sem.acquire().await;
6473            let mut seat = job.seat;
6474            let out = agent::invoke(
6475                &job.spec,
6476                &mut seat,
6477                &Invocation {
6478                    cwd: &job.cwd,
6479                    prompt: &job.prompt,
6480                    timeout: job.timeout,
6481                    allow_write: job.allow_write,
6482                    sessions: job.sessions,
6483                    artifacts: &job.artifacts,
6484                    stem: &job.stem,
6485                    run: &run,
6486                    node: &node,
6487                    cache_dir: cache.as_deref(),
6488                    attachments: &attachments,
6489                    writable: &[],
6490                },
6491            )
6492            .await;
6493            let out = match out {
6494                Ok(o) if o.usable() => AgentOutcome::Ok(o),
6495                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6496                // Billed work the CLI failed to hand over is not an ordinary
6497                // failure, but its text is the CLI's raw error JSON, not an
6498                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
6499                // read it as one by forgetting to check. `usable()` is always
6500                // false here (dropped implies an empty response), so this has
6501                // to be checked before the catch-all `Failed` below or the
6502                // one shape this exists for is lost with the rest.
6503                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6504                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6505                Ok(o) => AgentOutcome::Failed(format!(
6506                    "exited with {:?} and no usable output",
6507                    o.exit_code
6508                )),
6509                Err(e) => AgentOutcome::Failed(e.to_string()),
6510            };
6511            (i, seat, out)
6512        });
6513    }
6514    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6515    while let Some(joined) = set.join_next().await {
6516        let (i, seat, out) = match joined {
6517            Ok(v) => v,
6518            // No seat to clear: a panicked task never reported which one it
6519            // was. The defensive sweep below this loop is what stops that
6520            // seat's `active` entry from surviving forever.
6521            Err(e) => {
6522                tracing::error!("agent task panicked: {e}");
6523                continue;
6524            }
6525        };
6526        state.seat_finished(&seat.key);
6527        record_jobs(state, node, round, &seat.key, &out);
6528        if let Err(e) = state.save() {
6529            tracing::warn!("could not persist a seat's completion: {e:#}");
6530        }
6531        if collected.len() <= i {
6532            collected.resize_with(i + 1, || None);
6533        }
6534        collected[i] = Some((i, seat, out));
6535    }
6536    // Belt-and-braces for the panic branch above: every seat this exact batch
6537    // started shares this `(node, attempt)` pair, and every seat that finished
6538    // normally already cleared itself, so anything left tagged with it here
6539    // can only be a panicked task's leftover. Cleared unconditionally rather
6540    // than left to read as still answering forever.
6541    if state
6542        .active
6543        .values()
6544        .any(|a| a.node == node && a.attempt == attempt)
6545    {
6546        state
6547            .active
6548            .retain(|_, a| !(a.node == node && a.attempt == attempt));
6549        if let Err(e) = state.save() {
6550            tracing::warn!("could not persist the end of a wave: {e:#}");
6551        }
6552    }
6553    // Whether or not the lease above was actually held, several worktrees
6554    // may just have built into the cache with nothing here able to name one
6555    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
6556    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
6557    // clean it might not have strictly needed; trusting a stale match would
6558    // cost it a wrong answer.
6559    if let Some(cache_dir) = cache
6560        && jobs_had_a_writer
6561    {
6562        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6563    }
6564    if let Some(guard) = cache_guard {
6565        guard.release();
6566    }
6567    collected.into_iter().flatten().collect()
6568}
6569
6570/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
6571/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
6572/// this is data collection, not the fix-specific completion contract in
6573/// [`Runner::continue_fix_report`], and applies regardless of which node
6574/// asked.
6575///
6576/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
6577/// evidence from; `Failed` does not, and correctly contributes nothing — a
6578/// timeout or crash is not itself evidence about a command the seat may have
6579/// started.
6580fn record_jobs(
6581    state: &mut RunState,
6582    node: &str,
6583    round: Option<usize>,
6584    seat: &str,
6585    out: &AgentOutcome,
6586) {
6587    let commands: &[agent::CommandEvidence] = match out {
6588        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6589        AgentOutcome::Failed(_) => &[],
6590    };
6591    let checked_at = Timestamp::now();
6592    for c in commands {
6593        state.jobs.push(JobRecord {
6594            node: node.to_owned(),
6595            round,
6596            seat: seat.to_owned(),
6597            id: c.id.clone(),
6598            description: c.description.clone(),
6599            checked_at,
6600            status: match c.exit_code {
6601                Some(0) => JobStatus::Completed,
6602                Some(_) => JobStatus::Failed,
6603                None => JobStatus::Unknown,
6604            },
6605            exit_code: c.exit_code,
6606            result_summary: c.result_summary.clone(),
6607            source: c.source.clone(),
6608        });
6609    }
6610}
6611
6612/// Is a review round clean, given how many reviewer seats answered against
6613/// how many the round expected?
6614///
6615/// A seat that never answered (timeout, crash, unparsable output) is not a
6616/// seat that read the patch and found nothing — treating it as such is
6617/// exactly the bug this function exists to close. Under the default `block`
6618/// policy a missing seat can never be clean; `warn` still requires the seats
6619/// that *did* answer to have found nothing blocking and verification to be
6620/// green.
6621///
6622/// `quota_missing` narrows that `block` default for exactly one cause of
6623/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
6624/// a session limit lifts by the very next round buys nothing — the seat is
6625/// asked again with the same quota — so once every missing seat is accounted
6626/// for by a quota loss (and at least one seat *did* answer, so a decision has
6627/// something to rest on) the round is decided on the panel that could answer,
6628/// same as `warn` would. A panel that lost every seat to quota is not
6629/// decided here: `answered == 0` falls through to the existing `block`
6630/// fallback so a fully collapsed panel still waits rather than landing on no
6631/// review at all.
6632fn round_is_clean(
6633    blocking: usize,
6634    e2e_ok: bool,
6635    answered: usize,
6636    expected: usize,
6637    quota_missing: usize,
6638    policy: IncompleteReviewPolicy,
6639) -> bool {
6640    if blocking != 0 || !e2e_ok {
6641        return false;
6642    }
6643    if answered == expected || policy == IncompleteReviewPolicy::Warn {
6644        return true;
6645    }
6646    answered > 0 && expected - answered <= quota_missing
6647}
6648
6649/// The review loop's own conclusion, derived entirely from its persisted
6650/// round records and the round budget that produced them — never from
6651/// `status`, so a reentry (or `gate`/`merge` reading it independently)
6652/// recomputes the identical answer regardless of what an earlier node in the
6653/// same walk, or a previous walk, did to `status`.
6654///
6655/// `None` while more rounds remain to try, including when review never ran
6656/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
6657/// gone clean, or the budget is spent, or the tree has stopped moving (see
6658/// [`STAGNANT_LIMIT`]), the answer is one of two things:
6659///
6660/// - An incomplete panel that raised nothing is missing input, not a
6661///   verified tree — never a hand-off candidate, whatever verification said
6662///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
6663/// - Otherwise, green e2e on the last round hands off (see
6664///   [`Runner::stop_reviewing`]); red e2e blocks.
6665///
6666/// A last round whose own verification is still `ResourceBlocked` — magi
6667/// itself never got a command to run, not evidence the patch is broken —
6668/// is neither: this returns `None` for it too, the same as "more rounds
6669/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
6670/// handling of that shape) instead of this cheap recomputation guessing a
6671/// verdict a real attempt never produced.
6672fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
6673    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
6674        return Some(RunStatus::Gating);
6675    }
6676    let last = reviews.last()?;
6677    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
6678    if reviews.len() < max_rounds && !stagnant {
6679        return None;
6680    }
6681    if last.incomplete() && last.blocking == 0 {
6682        return Some(RunStatus::Blocked);
6683    }
6684    if last.e2e_status() == E2eStatus::ResourceBlocked {
6685        return None;
6686    }
6687    Some(if last.e2e.iter().all(CommandOutcome::ok) {
6688        RunStatus::Gating
6689    } else {
6690        RunStatus::Blocked
6691    })
6692}
6693
6694/// How long a re-ask may take, given the budget the first attempt had.
6695///
6696/// A `nudged` retry is a request to restate an answer the seat has already
6697/// worked out: it carries no new work, so it does not deserve the original
6698/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
6699/// 133 seconds while a third sat for over ten minutes on a resumed session
6700/// holding 410 KB of prior output - and because the retry had inherited the
6701/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
6702/// judging round whose other seats were long finished.
6703///
6704/// A quarter of the budget, with a floor so that a deliberately short timeout
6705/// does not collapse to nothing. A retry that re-sends the whole prompt
6706/// (because the seat kept no context) is the original job again, and keeps the
6707/// original budget.
6708fn retry_budget(full: Duration, nudged: bool) -> Duration {
6709    if nudged {
6710        (full / 4).max(Duration::from_secs(120)).min(full)
6711    } else {
6712        full
6713    }
6714}
6715
6716/// Run a wave and parse each reply, re-asking the seats whose reply was
6717/// unusable.
6718///
6719/// The re-ask is a nudge rather than the whole prompt again when the seat still
6720/// holds its conversation, which is the difference between a cheap retry and
6721/// paying for the entire candidate set twice.
6722///
6723/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
6724/// successor in `roster` is handed to it instead of being re-asked: the
6725/// handover is the retry. A seat with no successor left (a single-agent
6726/// roster, the roster's tail) is nudged as before, up to `retries` times. So
6727/// the asks to one seat in one node number at most
6728/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
6729/// is asked once (a dropped stream is nudged first), and only the last agent
6730/// of the chain gets the `retries` same-agent nudges. Each roster agent is
6731/// tried at most once per seat, walking forward from the seat's own position and never wrapping
6732/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
6733/// other failure stops the chain when the previous agent failed the same way
6734/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
6735/// [`has_context`] is false and the job's own full prompt and full budget are
6736/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
6737/// intermediate ones are not losses) and is returned as a failure like any
6738/// other absent seat — the caller decides whether the panel still has a
6739/// quorum. An empty `roster` disables handover: failures are nudged as they
6740/// always were, and a quota is simply lost. A reply that fails to parse or
6741/// validate is the prompt's doing and is only ever nudged, never handed over.
6742///
6743/// The returned [`SeatState`] names the agent that answered (or tried last).
6744#[allow(clippy::too_many_arguments)]
6745async fn ask_json_wave<T>(
6746    jobs: Vec<SeatJob>,
6747    sem: Arc<Semaphore>,
6748    retries: usize,
6749    roster: &[AgentSpec],
6750    ctx: &WaveCtx<'_>,
6751    losses: &mut Vec<QuotaLoss>,
6752    state: &mut RunState,
6753    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
6754) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
6755where
6756    T: serde::de::DeserializeOwned + Send + 'static,
6757{
6758    let n = jobs.len();
6759    let originals: Vec<SeatJob> = jobs;
6760    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
6761    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
6762    // Nudges each seat's *current* agent has taken — 0 for a first-ask
6763    // answer, N once it has gone through N nudges. Read back once this
6764    // returns, so a caller building a history record (`ReviewRecord`) can
6765    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
6766    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
6767    // field's own doc.
6768    let mut nudges: Vec<usize> = vec![0; n];
6769    // The agent now occupying each seat, the ids it has already been through,
6770    // where in the roster the walk began, the class of the last failure, and
6771    // the stem word of a handover not yet asked (full prompt, full budget).
6772    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
6773    let mut tried: Vec<BTreeSet<String>> = specs
6774        .iter()
6775        .map(|s| BTreeSet::from([s.id.clone()]))
6776        .collect();
6777    let starts: Vec<usize> = specs
6778        .iter()
6779        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
6780        .collect();
6781    let mut prev: Vec<Option<FailClass>> = vec![None; n];
6782    let mut fresh: Vec<Option<String>> = vec![None; n];
6783    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
6784    let mut pending: Vec<usize> = (0..n).collect();
6785
6786    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
6787    // asks: an agent with a successor is asked once and handed over, and only
6788    // a seat with no successor spends `retries` nudges on the same agent. This
6789    // only guarantees the loop's own termination whatever those say.
6790    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
6791    for round in 0..max_rounds {
6792        if pending.is_empty() {
6793            break;
6794        }
6795        let mut batch = Vec::with_capacity(pending.len());
6796        let mut renudged: Vec<&str> = Vec::new();
6797        for &i in &pending {
6798            let src = &originals[i];
6799            // The prompt and the budget are one decision: a nudge restates
6800            // finished work, a re-sent prompt redoes it.
6801            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
6802                (
6803                    src.prompt.clone(),
6804                    src.timeout,
6805                    format!("{}-{word}-{}", src.stem, specs[i].id),
6806                )
6807            } else if nudges[i] == 0 {
6808                (src.prompt.clone(), src.timeout, src.stem.clone())
6809            } else {
6810                renudged.push(src.seat.key.as_str());
6811                let why = done[i]
6812                    .as_ref()
6813                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
6814                    .unwrap_or_else(|| "no parsable answer".to_owned());
6815                let nudge = prompt::nudge(&why);
6816                let nudged = has_context(&specs[i], &seats[i], src.sessions);
6817                let prompt = if nudged {
6818                    nudge
6819                } else {
6820                    format!("{}\n\n---\n\n{}", src.prompt, nudge)
6821                };
6822                (
6823                    prompt,
6824                    retry_budget(src.timeout, nudged),
6825                    format!("{}-retry{}", src.stem, nudges[i]),
6826                )
6827            };
6828            batch.push(SeatJob {
6829                spec: specs[i].clone(),
6830                seat: seats[i].clone(),
6831                cwd: src.cwd.clone(),
6832                prompt,
6833                timeout,
6834                allow_write: src.allow_write,
6835                sessions: src.sessions,
6836                artifacts: src.artifacts.clone(),
6837                stem,
6838            });
6839        }
6840
6841        if !renudged.is_empty() {
6842            state.event(
6843                ctx.node,
6844                format!("retry {round}: re-asking {}", renudged.join(", ")),
6845            );
6846        }
6847        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
6848        let mut still = Vec::new();
6849        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
6850            seats[i] = seat;
6851            let class = FailClass::of(&out);
6852            // A dropped stream is nudged first (the conversation is still
6853            // there to pick up); only a seat whose nudges are spent hands over.
6854            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
6855                && nudges[i] < retries
6856                && !roster.is_empty();
6857            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
6858                let next = should_hand_over(prev[i].as_ref(), &cur)
6859                    .then(|| next_untried_in_roster(roster, starts[i], &tried[i]).cloned())
6860                    .flatten();
6861                if let Some(next) = next {
6862                    record_handover(
6863                        state,
6864                        ctx.node,
6865                        &originals[i].seat.key,
6866                        &specs[i].id,
6867                        &next.id,
6868                        &cur,
6869                        &fail_reason(&out),
6870                    );
6871                    tried[i].insert(next.id.clone());
6872                    prev[i] = Some(cur.clone());
6873                    seats[i] = handover_seat(&originals[i].seat.key, &next.id, state.seed);
6874                    specs[i] = next;
6875                    fresh[i] = Some(cur.stem_word().to_owned());
6876                    nudges[i] = 0;
6877                    done[i] = Some(Err(anyhow::anyhow!(
6878                        "handed over after: {}",
6879                        fail_reason(&out)
6880                    )));
6881                    still.push(i);
6882                    continue;
6883                }
6884            }
6885            let parsed = match out {
6886                AgentOutcome::Ok(o) => match verdict::extract_json::<T>(&o.text) {
6887                    Ok(v) => match validate(&v) {
6888                        Ok(()) => Ok((v, o)),
6889                        Err(e) => Err(e),
6890                    },
6891                    Err(e) => Err(e),
6892                },
6893                AgentOutcome::Quota(o) => {
6894                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
6895                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
6896                }
6897                // Not a parseable answer: the nudge loop re-asks it, which is
6898                // exactly what a dropped stream needs. Just don't hand its raw
6899                // error JSON to `extract_json`.
6900                AgentOutcome::Dropped(o) => {
6901                    let why = o
6902                        .dropped
6903                        .as_ref()
6904                        .map(|d| d.why.as_str())
6905                        .unwrap_or("the CLI ended the stream without delivering its answer");
6906                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
6907                }
6908                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
6909            };
6910            let quota = class == Some(FailClass::Quota);
6911            let failed = parsed.is_err();
6912            done[i] = Some(parsed);
6913            // Do not re-ask a rate-limited seat (quota) — a retry is known to
6914            // fail the same way; and never re-ask a seat that already parsed.
6915            // A failed agent that still has a successor is not re-asked
6916            // either: the handover was its remedy and has just been refused
6917            // (the chain stops on a repeated failure class). A seat with no
6918            // successor left (a single-agent roster, the roster's tail, or an
6919            // empty roster) keeps the same-agent nudge, bounded by `retries`.
6920            let agent_failure = class.is_some()
6921                && !nudge_first
6922                && !roster.is_empty()
6923                && next_untried_in_roster(roster, starts[i], &tried[i]).is_some();
6924            if failed && !quota && !agent_failure && nudges[i] < retries {
6925                nudges[i] += 1;
6926                still.push(i);
6927            }
6928        }
6929        pending = still;
6930    }
6931
6932    // One loss per seat whose chain ended on a quota: the intermediate ones
6933    // were absorbed by a handover and are not losses.
6934    for (i, q) in last_quota.into_iter().enumerate() {
6935        if let Some(reset) = q {
6936            losses.push(QuotaLoss {
6937                seat: originals[i].seat.key.clone(),
6938                node: ctx.node.to_owned(),
6939                at: Timestamp::now(),
6940                reset,
6941            });
6942        }
6943    }
6944
6945    seats
6946        .into_iter()
6947        .zip(done)
6948        .zip(nudges)
6949        .map(|((seat, res), attempts)| {
6950            (
6951                seat,
6952                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
6953                attempts,
6954            )
6955        })
6956        .collect()
6957}
6958
6959/// Acquire the shared build cache's lease, waiting out contention within
6960/// `budget` (never past it — see AGENTS.md's build-cache section on why an
6961/// unbounded wait is never acceptable).
6962///
6963/// A first, non-blocking check happens before ever waiting; if it finds the
6964/// lease busy, that fact is logged as a `verify` event *and* flushed with
6965/// [`RunState::save`] immediately — not only once the wait finally succeeds
6966/// or gives up — so a `magi show` run by a different process while this one
6967/// is still waiting reads a `run.json` that says so, rather than whatever it
6968/// looked like before the wait started. The same applies to the terminal
6969/// failure: logged and saved before this returns `Err`, so a caller that
6970/// could not get the lease at all still leaves a legible record of why.
6971async fn acquire_cache_lease(
6972    state: &mut RunState,
6973    cache_dir: &Path,
6974    owner: &crate::cache::Owner,
6975    budget: Duration,
6976    context: &str,
6977) -> Result<crate::cache::Guard> {
6978    let home = crate::run::home();
6979    let started = Instant::now();
6980    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
6981        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
6982        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
6983        Err(e) => {
6984            state.event(
6985                "verify",
6986                format!("{context}: could not check the shared build cache: {e:#}"),
6987            );
6988            if let Err(e2) = state.save() {
6989                tracing::warn!("could not persist a cache-check failure: {e2:#}");
6990            }
6991            return Err(e);
6992        }
6993    };
6994    state.event(
6995        "verify",
6996        format!(
6997            "{context}: waiting for the shared build cache at {} ({})",
6998            cache_dir.display(),
6999            busy.describe()
7000        ),
7001    );
7002    if let Err(e) = state.save() {
7003        tracing::warn!("could not persist a cache wait: {e:#}");
7004    }
7005    let remaining = budget.saturating_sub(started.elapsed());
7006    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7007        Ok(g) => Ok(g),
7008        Err(e) => {
7009            state.event("verify", format!("{context}: {e:#}"));
7010            if let Err(e2) = state.save() {
7011                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7012            }
7013            Err(e)
7014        }
7015    }
7016}
7017
7018/// Run `body` — a verify command batch — while holding the shared build
7019/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
7020/// never interleave with another borrower's build against the same
7021/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
7022/// timeout, or a human's own `magi review`. See the `cache` module doc for
7023/// why this matters more than Cargo's own per-target locking covers — two
7024/// *different* worktrees building the same package name/version into one
7025/// cache directory is a staleness bug, not a lock contention one.
7026///
7027/// The wait for the lease is carved out of `budget`, never on top of it —
7028/// `body` is handed whatever is left, so a caller's own node timeout is the
7029/// only clock involved, exactly what AGENTS.md's build-cache section asks
7030/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
7031/// cache configured at all — this is a pass-through: `body` runs with the
7032/// full budget and nothing is leased.
7033///
7034/// A lease that cannot be acquired within `budget` is reported as a single
7035/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
7036/// verification — the same shape a spawn failure already takes in
7037/// [`run_commands`], so a caller need not special-case it.
7038#[allow(clippy::too_many_arguments)]
7039async fn with_cache_lease<'s, F, Fut>(
7040    state: &'s mut RunState,
7041    cache_dir: Option<&Path>,
7042    node: &str,
7043    seat: &str,
7044    worktree: &Path,
7045    head: &str,
7046    budget: Duration,
7047    context: &str,
7048    body: F,
7049) -> (Vec<CommandOutcome>, bool)
7050where
7051    F: FnOnce(&'s mut RunState, Duration) -> Fut,
7052    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7053{
7054    let Some(cache_dir) = cache_dir else {
7055        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7056        return (outcomes, retried);
7057    };
7058    let home = crate::run::home();
7059    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7060    let started = Instant::now();
7061    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7062        Ok(g) => g,
7063        Err(e) => {
7064            return (
7065                vec![CommandOutcome {
7066                    command: "(waiting for the shared build cache)".to_owned(),
7067                    code: None,
7068                    output_tail: e.to_string(),
7069                    duration_ms: started.elapsed().as_millis() as u64,
7070                    resource_blocked: true,
7071                }],
7072                false,
7073            );
7074        }
7075    };
7076    let identity = crate::cache::Identity::new(worktree, head);
7077    if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7078        // A failed freshness check means this process cannot vouch for what
7079        // is sitting in the cache right now - on Windows this is exactly the
7080        // "a stale test executable is still locked, `cargo clean -p` cannot
7081        // remove it" case the evidence log records. Running verify anyway
7082        // and reporting whatever it says would let a result nobody can trust
7083        // stand for the tree it claims to have checked; fail the step
7084        // instead of the patch.
7085        state.event(
7086            "verify",
7087            format!(
7088                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7089                worktree.display(),
7090                short(head)
7091            ),
7092        );
7093        guard.release();
7094        return (
7095            vec![CommandOutcome {
7096                command: "(confirming the shared build cache is fresh)".to_owned(),
7097                code: None,
7098                output_tail: e.to_string(),
7099                duration_ms: started.elapsed().as_millis() as u64,
7100                resource_blocked: true,
7101            }],
7102            false,
7103        );
7104    }
7105    let remaining = budget.saturating_sub(started.elapsed());
7106    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7107    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
7108    // `start_kill`); confirm it actually has before handing the directory to
7109    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
7110    // for what this can and cannot see.
7111    if !timed_out_pids.is_empty() {
7112        wait_for_timed_out_children_to_die(&timed_out_pids).await;
7113    }
7114    guard.release();
7115    (outcomes, retried)
7116}
7117
7118/// Poll `pids` — commands [`run_commands`] reports as still running when its
7119/// own timeout elapsed — until every one is confirmed gone, or
7120/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
7121///
7122/// Real confirmation where confirmation is possible, not a substitute for
7123/// full process-tree observation: a grandchild the timed-out process spawned
7124/// and that survives independently of it is invisible to a pid check the
7125/// same way it always was, and continuing to observe and collect *that*
7126/// stays a different piece of work with its own owner. This only narrows a
7127/// fixed blind wait into an actual check of the pids this process does know
7128/// about.
7129async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7130    wait_for_pids_with(
7131        pids,
7132        crate::proc::pid_alive,
7133        LEASE_RELEASE_POLL,
7134        LEASE_RELEASE_MAX_WAIT,
7135    )
7136    .await;
7137}
7138
7139/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
7140/// interval and ceiling supplied by the caller, so the polling *logic* -
7141/// returns as soon as every pid reports dead, gives up at the ceiling
7142/// otherwise - is testable on millisecond durations without asking the real
7143/// OS about a pid at all.
7144async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7145    pids: &[u32],
7146    alive: F,
7147    poll: Duration,
7148    max_wait: Duration,
7149) {
7150    let deadline = Instant::now() + max_wait;
7151    loop {
7152        if pids.iter().all(|&pid| !alive(pid)) {
7153            return;
7154        }
7155        if Instant::now() >= deadline {
7156            return;
7157        }
7158        tokio::time::sleep(poll).await;
7159    }
7160}
7161
7162/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
7163/// admission that it could not even get a verify command to run, as opposed
7164/// to evidence the command actually produced? A caller that would otherwise
7165/// read a resource-blocked outcome as a red command must check this first:
7166/// see [`Runner::gate`], which retries rather than records `Blocked` when
7167/// this is true.
7168fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7169    outcomes.iter().any(|o| o.resource_blocked)
7170}
7171
7172/// What [`Runner::gate_fix_round`] decided.
7173enum GateFix {
7174    /// The tree changed and `verify.e2e` is still green: run the gate again.
7175    Retry,
7176    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
7177    /// last failure stands and the run ends blocked.
7178    Stop,
7179    /// `verify.e2e` could not run after the fix (magi's own contention):
7180    /// decide nothing now, a later reentry retries.
7181    Defer,
7182}
7183
7184/// Is every red command in `outcomes` an ordinary failure the code could
7185/// explain: it ran, exited non-zero, and said something?
7186///
7187/// A timeout, a spawn failure and a killed process all leave `code` `None`;
7188/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
7189/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
7190/// what the command is stays the gate's business.
7191fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7192    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7193    red.peek().is_some()
7194        && red.all(|o| {
7195            !o.resource_blocked
7196                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7197                && !o.output_tail.trim().is_empty()
7198        })
7199}
7200
7201/// Describe one verify command's outcome for the event log, distinguishing a
7202/// build/link failure — the toolchain never produced a binary to run — from
7203/// an actual test failure, since only the latter is a verdict on the patch.
7204fn e2e_outcome_label(o: &CommandOutcome) -> String {
7205    if o.ok() {
7206        return "pass".to_owned();
7207    }
7208    let reason = if o.build_failed() {
7209        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7210    } else {
7211        format!("FAIL ({:?})", o.code)
7212    };
7213    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7214}
7215
7216/// Run `verify.e2e`, retrying once if the first attempt could not build or
7217/// link — a build/link failure is frequently a race against a shared
7218/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
7219/// `verify` event per command, tagged with `context` (normally `"round N"`)
7220/// so the two call sites that need this — the ordinary per-round leg in
7221/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
7222/// it will ever call a round green — read identically in the event log.
7223async fn run_e2e_with_retry(
7224    state: &mut RunState,
7225    shell: &[String],
7226    commands: &[String],
7227    worktree: &Path,
7228    timeout: Duration,
7229    context: &str,
7230) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7231    let (mut e2e, mut timed_out_pids) = run_commands(
7232        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7233    )
7234    .await;
7235    for o in &e2e {
7236        state.event(
7237            "verify",
7238            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7239        );
7240    }
7241    // A build/link failure is not a verdict on the patch — it is frequently a
7242    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
7243    // one retry before letting a red like that decide the round.
7244    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7245    if verify_retried {
7246        state.event(
7247            "verify",
7248            format!(
7249                "{context}: verify could not build/link, not a test result — retrying once \
7250                 before concluding"
7251            ),
7252        );
7253        let retried = run_commands(
7254            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7255        )
7256        .await;
7257        e2e = retried.0;
7258        // Both attempts' timeouts matter, not just the last one: the first
7259        // attempt's descendants may still be alive alongside the retry's.
7260        timed_out_pids.extend(retried.1);
7261        for o in &e2e {
7262            state.event(
7263                "verify",
7264                format!(
7265                    "{context}: retry `{}` -> {}",
7266                    o.command,
7267                    e2e_outcome_label(o)
7268                ),
7269            );
7270        }
7271    }
7272    (e2e, verify_retried, timed_out_pids)
7273}
7274
7275/// Run configured shell commands in `cwd`, in order. The second element is
7276/// the pid of every command that hit `timeout` and was still running when
7277/// this stopped waiting on it (best-effort: `None` when the platform did not
7278/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
7279/// that releases a shared resource afterward needs to know.
7280///
7281/// Records `task` into [`RunState::active`] at every command boundary
7282/// (`RunState::task_command`) and clears it once the whole list has run
7283/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
7284/// for minutes with no seat and no output of its own to show for it (see
7285/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
7286/// apart from "not yet run" without this), and this is the only place that
7287/// knows which command is running right now and how many are left. Three
7288/// saves per command — start, not per second — matching the same "only at a
7289/// boundary" rule [`wave`] already follows for seats.
7290#[allow(clippy::too_many_arguments)]
7291async fn run_commands(
7292    state: &mut RunState,
7293    node: &str,
7294    task: &str,
7295    attempt: usize,
7296    shell: &[String],
7297    commands: &[String],
7298    cwd: &Path,
7299    timeout: Duration,
7300) -> (Vec<CommandOutcome>, Vec<u32>) {
7301    if commands.is_empty() {
7302        // Nothing to mark as running and nothing to clear — an empty list
7303        // means "not configured", and touching `active` (or the disk) over
7304        // that would be a write for every round of a repo with no
7305        // `verify.e2e` / `verify.gate` commands at all.
7306        return (Vec::new(), Vec::new());
7307    }
7308    let mut out = Vec::new();
7309    let mut timed_out_pids = Vec::new();
7310    let total = commands.len();
7311    for (idx, command) in commands.iter().enumerate() {
7312        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7313        if let Err(e) = state.save() {
7314            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7315        }
7316        let started = Instant::now();
7317        let mut cmd = tokio::process::Command::new(&shell[0]);
7318        cmd.quiet();
7319        cmd.args(&shell[1..])
7320            .arg(command)
7321            .current_dir(cwd)
7322            .stdin(std::process::Stdio::null())
7323            .stdout(std::process::Stdio::piped())
7324            .stderr(std::process::Stdio::piped())
7325            .kill_on_drop(true);
7326        let spawned = cmd.spawn();
7327        let (code, body) = match spawned {
7328            Ok(child) => {
7329                // Captured before the child is consumed below: `kill_on_drop`
7330                // only *asks* the process to die when the timeout branch
7331                // drops it, and the pid is the only way anyone downstream can
7332                // later check whether that request actually took.
7333                let pid = child.id();
7334                match tokio::time::timeout(timeout, child.wait_with_output()).await {
7335                    Ok(Ok(o)) => {
7336                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7337                        body.push_str(&String::from_utf8_lossy(&o.stderr));
7338                        (o.status.code(), body)
7339                    }
7340                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
7341                    Err(_) => {
7342                        if let Some(pid) = pid {
7343                            timed_out_pids.push(pid);
7344                        }
7345                        (None, format!("timed out after {}s", timeout.as_secs()))
7346                    }
7347                }
7348            }
7349            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7350        };
7351        out.push(CommandOutcome {
7352            command: command.clone(),
7353            code,
7354            output_tail: tail(&body, OUTPUT_TAIL),
7355            duration_ms: started.elapsed().as_millis() as u64,
7356            resource_blocked: false,
7357        });
7358    }
7359    state.task_finished(task);
7360    if let Err(e) = state.save() {
7361        tracing::warn!("could not persist the end of {task}: {e:#}");
7362    }
7363    (out, timed_out_pids)
7364}
7365
7366/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
7367/// section (`report::run`) and in the `merge` event this node records — for
7368/// the operator to run by hand.
7369///
7370/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
7371/// branch whose ruleset forbids merge commits (GitHub's "must not contain
7372/// merge commits", or "require linear history") rejects the push a `--no-ff`
7373/// merge would produce, which is exactly the guidance this function replaces.
7374/// `message`'s first line becomes the squash commit's subject, matching the
7375/// note `report::run` prints alongside this command — see that function for
7376/// why an explicit subject is not optional there.
7377fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7378    let repo = repo.display();
7379    match style {
7380        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7381        MergeStyle::Squash => {
7382            // The subject sits inside double quotes, and a title an agent
7383            // wrote may carry the characters that break out of them.
7384            let subject = message
7385                .lines()
7386                .next()
7387                .unwrap_or(branch)
7388                .replace(['\\', '"', '$', '`'], "");
7389            format!(
7390                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7391            )
7392        }
7393        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7394    }
7395}
7396
7397/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
7398/// under the hood, rejects a `title` over 256 characters and the whole
7399/// command fails — no PR at all, for a run whose body was otherwise fine
7400/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
7401/// that limit: `title_from` counts `chars()` (Unicode scalars), which is not
7402/// always how GitHub counts, plus one character for the trailing ellipsis
7403/// `title_from` may add. It is a margin, not a guarantee — a title packed
7404/// with multi-unit characters could still in principle land close to the
7405/// edge, but a real task title's occasional emoji or accented letter fits
7406/// comfortably inside it.
7407const PR_TITLE_MAX: usize = 240;
7408
7409/// What `merge = "pr"` (and the merge commit of the other modes) says about a
7410/// change: a title and a body describing what was *implemented*, not the task
7411/// that asked for it. A task reads as a request; a reader of the merged
7412/// history wants the change.
7413struct PrMessage {
7414    title: String,
7415    body: String,
7416}
7417
7418impl PrMessage {
7419    /// Title, blank line, body. The first line is the squash/merge commit
7420    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
7421    /// has to stay one sensible line.
7422    fn commit_message(&self) -> String {
7423        format!("{}\n\n{}", self.title, self.body)
7424    }
7425}
7426
7427/// The text after a leading `TITLE:` (any case) on `line`.
7428fn title_marker(line: &str) -> Option<&str> {
7429    let line = line.trim();
7430    let head = line.get(..6)?;
7431    head.eq_ignore_ascii_case("title:")
7432        .then(|| line[6..].trim())
7433}
7434
7435/// The implementer's own one-line title: the `TITLE:` line the implement
7436/// prompt asks for at the top of its SUMMARY. Candidate commits are all
7437/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
7438/// source, and a title that says as much is refused here too.
7439fn summary_title(summary: &str) -> Option<String> {
7440    let first = summary.lines().find(|l| !l.trim().is_empty())?;
7441    let raw = title_marker(first)?;
7442    if raw.is_empty() {
7443        return None;
7444    }
7445    let title = queue::title_from(raw, PR_TITLE_MAX);
7446    let lower = title.to_ascii_lowercase();
7447    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7448        return None;
7449    }
7450    Some(title)
7451}
7452
7453/// How `open_review`'s instruction begins; see [`landing_title`].
7454const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7455
7456/// Marker `open_review` gives a candidate that nothing in the roster wrote.
7457const EXISTING_BRANCH: &str = "(existing branch)";
7458
7459/// Does this run review work that already existed, rather than implement a
7460/// task? Runs recorded before `reviewed_commits` existed carry only the
7461/// candidate marker.
7462fn is_review_run(state: &RunState) -> bool {
7463    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
7464}
7465
7466/// The title of a review-only run: the subject of the oldest commit under
7467/// review. Later commits are usually fixups, and `instruction` is the review
7468/// prompt, which says nothing about the change. GitHub text is English, so a
7469/// non-ASCII or blank subject yields `None` and the caller's neutral title.
7470fn review_title(state: &RunState) -> Option<String> {
7471    english_subject(state.reviewed_commits.as_ref()?.first()?)
7472}
7473
7474/// `raw` as a pull request title, or `None` when it is blank, not English
7475/// (GitHub text is), or one of magi's own candidate commit subjects.
7476fn english_subject(raw: &str) -> Option<String> {
7477    let raw = raw.trim();
7478    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
7479        return None;
7480    }
7481    let title = queue::title_from(raw, PR_TITLE_MAX);
7482    let lower = title.to_ascii_lowercase();
7483    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7484        return None;
7485    }
7486    Some(title)
7487}
7488
7489/// What a review-only run's branch says about itself, read at the moment the
7490/// pull request is opened.
7491#[derive(Debug, Clone, PartialEq, Eq)]
7492struct BranchFacts {
7493    /// `(subject, body)` of each commit, oldest first.
7494    commits: Vec<(String, String)>,
7495    /// Trimmed `git diff --stat`.
7496    stat: String,
7497}
7498
7499/// Longest diff stat shown: this many file lines plus the summary line.
7500const STAT_FILE_LINES: usize = 25;
7501/// Cap on the commit list, well inside GitHub's 65536-character body limit.
7502const COMMITS_MAX_CHARS: usize = 20_000;
7503
7504/// Read the commits and diff stat of `base..branch`. `None` when git cannot
7505/// say or finds nothing, so the caller falls back to what the run recorded.
7506async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
7507    let commits = git::commit_log(repo, base, branch).await.ok()?;
7508    if commits.is_empty() {
7509        return None;
7510    }
7511    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
7512    let lines: Vec<&str> = stat.lines().collect();
7513    let stat = if lines.len() > STAT_FILE_LINES + 1 {
7514        let omitted = lines.len() - 1 - STAT_FILE_LINES;
7515        let more = format!(" ... {omitted} more file(s)");
7516        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
7517        kept.push(&more);
7518        kept.push(lines[lines.len() - 1]);
7519        kept.join("\n")
7520    } else {
7521        lines.join("\n")
7522    };
7523    Some(BranchFacts { commits, stat })
7524}
7525
7526/// Defang what would break the surrounding markdown: a closing `</details>`
7527/// and a code fence.
7528fn markdown_safe(text: &str) -> String {
7529    text.replace("</details>", "&lt;/details&gt;")
7530        .replace("\x60\x60\x60", "~~~")
7531}
7532
7533fn neutral_title(state: &RunState, winner: char) -> String {
7534    format!(
7535        "chore: land candidate {} of run {}",
7536        winner.to_ascii_uppercase(),
7537        state.id
7538    )
7539}
7540
7541/// The pull request title to hand to `land::merge_subject`. A review-only run
7542/// opened by an earlier build titled its pull request with the review prompt;
7543/// that title is dropped (empty, so the fallback applies) rather than landed.
7544/// Any other title, including an operator's rename, passes through untouched,
7545/// and so does every title of a run that implements a task.
7546pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
7547    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
7548        ""
7549    } else {
7550        pr_title
7551    }
7552}
7553
7554/// What the squash subject falls back to when the pull request title is empty
7555/// or candidate-shaped: for a review-only run the derived title, never the
7556/// review prompt held in `instruction`.
7557pub fn landing_subject_source(state: &RunState) -> String {
7558    if is_review_run(state) {
7559        let winner = state.candidates.first().map_or('A', |c| c.label);
7560        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
7561    }
7562    state.instruction.clone()
7563}
7564
7565/// `summary` without its `TITLE:` line, which the pull request title already
7566/// carries.
7567fn summary_without_title(summary: &str) -> String {
7568    let mut lines = summary.trim().lines().peekable();
7569    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
7570        lines.next();
7571    }
7572    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
7573}
7574
7575/// The pull request title and body for the winning candidate.
7576///
7577/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
7578/// the task's own opening line via [`queue::title_from`] when there is none.
7579/// `state.instruction` can open with blank lines (`task_text` only rejects a
7580/// body that is blank *entirely*), which `title_from` skips.
7581///
7582/// Body: the implementer's summary and the fixer's notes, then — when the
7583/// winning review round was not clean — the findings still open and whatever
7584/// the fixer declined, so `merge = "pr"` hands the reader the same material
7585/// `magi show` does. The task follows inside a collapsed block, and the
7586/// footer repeats the run and candidate as plain tags for a reader holding
7587/// only the merged commit or the PR body.
7588#[cfg(test)]
7589fn pr_message(state: &RunState, winner: char) -> PrMessage {
7590    pr_message_with(state, winner, None)
7591}
7592
7593/// [`pr_message`] with what the branch of a review-only run says about itself.
7594/// `facts` is ignored for a run that implements a task.
7595fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
7596    let summary = state
7597        .candidates
7598        .iter()
7599        .find(|c| c.label == winner)
7600        .map(|c| c.summary.as_str())
7601        .unwrap_or_default();
7602    // The fallback is the operator's own words and may not be English; GitHub
7603    // text always is, so a non-English task gets a neutral title instead.
7604    let review = is_review_run(state);
7605    let title = if review {
7606        facts
7607            .and_then(|f| english_subject(&f.commits.first()?.0))
7608            .or_else(|| review_title(state))
7609            .or_else(|| {
7610                state
7611                    .candidates
7612                    .iter()
7613                    .find(|c| c.label == winner)
7614                    .filter(|c| !c.branch.starts_with("magi/"))
7615                    .and_then(|c| english_subject(&c.branch))
7616            })
7617            .unwrap_or_else(|| neutral_title(state, winner))
7618    } else {
7619        summary_title(summary).unwrap_or_else(|| {
7620            let t = queue::title_from(&state.instruction, PR_TITLE_MAX);
7621            if t.is_ascii() && t.chars().any(|c| c.is_ascii_alphabetic()) {
7622                t
7623            } else {
7624                neutral_title(state, winner)
7625            }
7626        })
7627    };
7628
7629    let mut body = String::new();
7630    let what = summary_without_title(summary);
7631    if !what.is_empty() {
7632        body.push_str("## Summary\n\n");
7633        body.push_str(&what);
7634        body.push_str("\n\n");
7635    }
7636
7637    // The last round is usually a clean verification pass with no fix of its
7638    // own, so every round's notes are read, not just the final one's.
7639    let notes: Vec<(usize, &str)> = state
7640        .reviews
7641        .iter()
7642        .filter_map(|r| {
7643            let n = r.fix.as_ref()?.notes.trim();
7644            (!n.is_empty()).then_some((r.round, n))
7645        })
7646        .collect();
7647    if !notes.is_empty() {
7648        body.push_str("## Review fixes\n\n");
7649        if let [(_, only)] = notes.as_slice() {
7650            body.push_str(only);
7651            body.push_str("\n\n");
7652        } else {
7653            for (round, n) in &notes {
7654                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
7655            }
7656        }
7657    }
7658    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
7659
7660    let open = state.open_findings();
7661    if !open.is_empty() {
7662        body.push_str("## Open review findings\n\n");
7663        for f in &open {
7664            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
7665        }
7666        body.push('\n');
7667    }
7668
7669    if let Some(fix) = fix
7670        && !fix.rejected.is_empty()
7671    {
7672        body.push_str("## Declined by the fixer\n\n");
7673        for r in &fix.rejected {
7674            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
7675        }
7676        body.push('\n');
7677    }
7678
7679    if review {
7680        // The review prompt is not the task; list what the branch carries.
7681        body.push_str("## Commits under review\n\n");
7682        if let Some(facts) = facts {
7683            let mut left = COMMITS_MAX_CHARS;
7684            for (i, (subject, text)) in facts.commits.iter().enumerate() {
7685                let mut entry = format!("- {}\n", markdown_safe(subject));
7686                for l in markdown_safe(text).lines() {
7687                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
7688                }
7689                if left == 0 {
7690                    body.push_str(&format!(
7691                        "- ... {} more commit(s)\n",
7692                        facts.commits.len() - i
7693                    ));
7694                    break;
7695                }
7696                if entry.len() > left {
7697                    // Even the first commit is cut: one huge body must not
7698                    // push the whole description past GitHub's limit.
7699                    let mut end = left;
7700                    while !entry.is_char_boundary(end) {
7701                        end -= 1;
7702                    }
7703                    entry.truncate(end);
7704                    entry.push_str("\n  ... (truncated)\n");
7705                    left = 0;
7706                } else {
7707                    left -= entry.len();
7708                }
7709                body.push_str(&entry);
7710            }
7711            if !facts.stat.trim().is_empty() {
7712                body.push_str(&format!(
7713                    "\n## Diff stat\n\n```\n{}\n```\n",
7714                    markdown_safe(facts.stat.trim())
7715                ));
7716            }
7717        } else {
7718            match &state.reviewed_commits {
7719                Some(subjects) => {
7720                    for s in subjects {
7721                        body.push_str(&format!("- {}\n", s.trim()));
7722                    }
7723                }
7724                None => {
7725                    // An older run kept only the prompt, with the commit list
7726                    // after its first paragraph.
7727                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
7728                    body.push_str(rest.trim());
7729                    body.push('\n');
7730                }
7731            }
7732        }
7733    } else {
7734        let task = state.instruction.trim();
7735        let task = if task.is_empty() {
7736            "(empty task)"
7737        } else {
7738            task
7739        };
7740        body.push_str(&format!(
7741            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
7742            task.replace("</details>", "&lt;/details&gt;")
7743        ));
7744    }
7745
7746    body.push_str(&format!(
7747        "\n---\nmagi:run/{} magi:candidate-{}\n",
7748        state.id,
7749        winner.to_ascii_lowercase()
7750    ));
7751
7752    // Prompts are advisory; this is the enforced half of the confidentiality
7753    // rule, and it covers the verbatim task in <details> too.
7754    let id = crate::scrub::Identity::current();
7755    PrMessage {
7756        title: crate::scrub::scrub(&title, &id),
7757        body: crate::scrub::scrub(&body, &id),
7758    }
7759}
7760
7761/// The task with what the repository says about the existing work it names
7762/// appended, so an implementer knows what it started from and what it must
7763/// not redo. Unchanged when the task names nothing.
7764fn seeded_instruction(state: &RunState) -> String {
7765    match refs::describe(&state.seeds) {
7766        Some(facts) => format!(
7767            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
7768             Candidates start from the unmerged branch named above, when there \
7769             is one, and carry any unmerged commit named by sha as a \
7770             cherry-pick. Check that this is what the task meant before \
7771             building on it.",
7772            state.instruction
7773        ),
7774        None => state.instruction.clone(),
7775    }
7776}
7777
7778/// Does the winner have no commits ahead of the base it would land on?
7779/// Any failure to find out reads as "not empty": the merge then behaves as it
7780/// always did rather than refusing on a guess.
7781async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
7782    let base = &state.base_branch;
7783    let mut against = base.clone();
7784    if mode == MergeMode::Pr {
7785        let remote = &state.config.merge.remote;
7786        let tracking = format!("{remote}/{base}");
7787        let fetched = git::fetch(repo, remote, base).await;
7788        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
7789            against = tracking;
7790        }
7791    }
7792    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
7793}
7794
7795/// Why nothing was opened for an empty winner, with what the task's own
7796/// references resolved to.
7797fn empty_candidate_detail(state: &RunState, base: &str) -> String {
7798    let mut detail = format!(
7799        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
7800         nothing to open a pull request for"
7801    );
7802    match refs::describe(&state.seeds) {
7803        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
7804        None => detail.push_str(
7805            "\nThe task names no existing branch or commit; if it means to land work \
7806             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
7807        ),
7808    }
7809    detail
7810}
7811
7812/// What the `Pr` merge does once it knows whether the branch already has an
7813/// open pull request.
7814#[derive(Debug, PartialEq, Eq)]
7815enum PrPlan {
7816    Create,
7817    Adopt { url: String, title: String },
7818    Stop(String),
7819}
7820
7821/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
7822/// a failed lookup -> stop with the real reason. Never guesses.
7823fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
7824    match found {
7825        Ok(land::OpenPr::None) => PrPlan::Create,
7826        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
7827        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
7828            "several open pull requests exist for this branch, not picking one: {}",
7829            urls.join(" ")
7830        )),
7831        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
7832    }
7833}
7834
7835/// `gh pr create`, returning the PR url.
7836async fn gh_pr_create(
7837    cwd: &Path,
7838    base: &str,
7839    head: &str,
7840    title: &str,
7841    body: &str,
7842) -> Result<String> {
7843    let out = tokio::process::Command::new("gh")
7844        .args([
7845            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
7846        ])
7847        .current_dir(cwd)
7848        .quiet()
7849        .stdin(std::process::Stdio::null())
7850        .output()
7851        .await
7852        .context("spawn gh")?;
7853    if out.status.success() {
7854        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
7855    } else {
7856        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
7857    }
7858}
7859
7860/// Tear a run's worktrees and branches down.
7861///
7862/// `home` is where the updated `run.json` is saved (via
7863/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
7864/// a housekeeping pass already has its own honest `home` handed to it, and
7865/// falling through to the global here would write back through whichever
7866/// directory some other process or test pinned into that `OnceLock` first,
7867/// not the one the caller actually resolved its `runs` and `state` from.
7868pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
7869    let repo = state.repo.clone();
7870    let root = state.worktree_root();
7871    let winner = state.tally.as_ref().map(|t| t.winner);
7872    let mut removed = Vec::new();
7873
7874    for i in 0..state.candidates.len() {
7875        let c = state.candidates[i].clone();
7876        let is_winner = Some(c.label) == winner;
7877        if is_winner && !drop_winner {
7878            continue;
7879        }
7880        if c.worktree.exists() {
7881            git::worktree_remove(&repo, &c.worktree).await.ok();
7882            removed.push(c.worktree.to_string_lossy().into_owned());
7883        }
7884        // A branch handed to a later run (and its pull request) is not this
7885        // run's to delete.
7886        let handed_over = state.released_branches.contains(&c.branch);
7887        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
7888            git::branch_delete(&repo, &c.branch).await.ok();
7889            removed.push(c.branch.clone());
7890        }
7891        state.candidates[i].folded = true;
7892    }
7893
7894    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
7895        let path = name.path();
7896        let keep = !drop_winner
7897            && winner.is_some_and(|w| {
7898                path.file_name()
7899                    .is_some_and(|n| n == format!("cand-{w}").as_str())
7900            });
7901        if keep {
7902            continue;
7903        }
7904        git::worktree_remove(&repo, &path).await.ok();
7905        removed.push(path.to_string_lossy().into_owned());
7906    }
7907
7908    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
7909    // judge worktrees, so once the loop above has cleared all of them out,
7910    // the parent is a bare directory nobody else was ever going to remove -
7911    // git only ever managed what was inside it. Left alone, one of these
7912    // accumulates per fully-folded run; the operator's own machine had 74.
7913    // `remove_if_empty` re-checks rather than assuming: a run whose winner
7914    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
7915    // so does anything a run never claimed that happens to share the bay.
7916    remove_if_empty(&root);
7917
7918    if state.enabled_worktree_config && drop_winner {
7919        // A release, not a raw disable: some sibling run in this repository
7920        // may still hold its own reference (see `git::acquire_worktree_config`),
7921        // and only the last release actually turns the setting back off.
7922        git::release_worktree_config(&repo).await.ok();
7923        state.enabled_worktree_config = false;
7924    }
7925    state.save_under(home)?;
7926    Ok(removed)
7927}
7928
7929/// Remove `dir` if it exists and has nothing in it.
7930///
7931/// Best-effort and silent by design: a directory that is not empty (a run
7932/// whose winner is still parked there, a stray file some other process left)
7933/// is exactly the case this must refuse, and a directory that is already gone
7934/// is not a failure worth reporting either. `std::fs::remove_dir` itself
7935/// already refuses a non-empty directory, so the emptiness check below is
7936/// belt, not suspenders - it is what keeps this from ever attempting the
7937/// removal in the case that matters, rather than trusting `remove_dir`'s
7938/// error path to have no side effects if it ever changed.
7939fn remove_if_empty(dir: &Path) {
7940    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
7941        std::fs::remove_dir(dir).ok();
7942    }
7943}
7944
7945/// Severity of the worst open finding in the last review round, for reporting.
7946pub fn worst_open(state: &RunState) -> Option<Severity> {
7947    state
7948        .reviews
7949        .last()?
7950        .reviews
7951        .iter()
7952        .flat_map(|r| r.findings.iter())
7953        .map(|f| f.severity)
7954        .max()
7955}
7956
7957#[cfg(test)]
7958mod tests {
7959    #[test]
7960    fn pr_merge_plan_creates_adopts_or_stops() {
7961        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
7962        assert_eq!(
7963            pr_merge_plan(Ok(land::OpenPr::One {
7964                url: "u".into(),
7965                title: "t".into()
7966            })),
7967            PrPlan::Adopt {
7968                url: "u".into(),
7969                title: "t".into()
7970            }
7971        );
7972        let PrPlan::Stop(many) =
7973            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
7974        else {
7975            panic!("many must stop");
7976        };
7977        assert!(many.contains('a') && many.contains('b'));
7978        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
7979            panic!("a failed lookup must stop");
7980        };
7981        assert!(err.contains("bad token"));
7982    }
7983
7984    use super::*;
7985    use crate::run::GateStatus;
7986    use std::collections::BTreeMap;
7987    use std::time::Duration;
7988
7989    fn conductor() -> AgentSpec {
7990        AgentSpec {
7991            id: "conductor".to_owned(),
7992            kind: crate::config::AgentKind::Command,
7993            model: None,
7994            command: vec!["true".to_owned()],
7995            extra_args: Vec::new(),
7996            env: BTreeMap::new(),
7997            prompt_delivery: None,
7998        }
7999    }
8000
8001    fn spec(id: &str) -> AgentSpec {
8002        AgentSpec {
8003            id: id.to_owned(),
8004            kind: crate::config::AgentKind::Command,
8005            model: None,
8006            command: vec!["true".to_owned()],
8007            extra_args: Vec::new(),
8008            env: BTreeMap::new(),
8009            prompt_delivery: None,
8010        }
8011    }
8012
8013    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
8014    // fallback loop depends on to terminate: it must walk forward from the
8015    // seat's own position, never restart at the front of the roster, and it
8016    // must never hand back an id already tried, however many times that id
8017    // happens to appear.
8018
8019    #[test]
8020    fn failure_signature_ignores_numbers_and_paths() {
8021        assert_eq!(
8022            failure_signature("exited with Some(2) and no usable output"),
8023            failure_signature("exited with Some(137) and no usable output")
8024        );
8025        assert_eq!(
8026            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8027            failure_signature("cannot open /var/x.txt: denied")
8028        );
8029        assert_ne!(failure_signature("boom"), failure_signature("bang"));
8030    }
8031
8032    #[test]
8033    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8034        let other = FailClass::Other("x".into());
8035        assert!(should_hand_over(None, &FailClass::Quota));
8036        assert!(should_hand_over(Some(&other), &FailClass::Quota));
8037        assert!(should_hand_over(
8038            Some(&FailClass::Timeout),
8039            &FailClass::Timeout
8040        ));
8041        assert!(should_hand_over(None, &other));
8042        assert!(!should_hand_over(Some(&other), &other));
8043        assert!(should_hand_over(
8044            Some(&other),
8045            &FailClass::Other("y".into())
8046        ));
8047        // A quota or timeout in between ends the run of identical failures.
8048        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8049        assert!(should_hand_over(Some(&FailClass::Quota), &other));
8050    }
8051
8052    #[test]
8053    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8054        let a = SeatState::new("judge-1", "alpha", 7);
8055        let b = handover_seat("judge-1", "beta", 7);
8056        assert_ne!(a.claude_session, b.claude_session);
8057        assert_eq!(b.turns, 0);
8058    }
8059
8060    #[test]
8061    fn a_timeout_is_classified_apart_from_other_failures() {
8062        assert_eq!(
8063            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8064            Some(FailClass::Timeout)
8065        );
8066        assert!(matches!(
8067            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8068            Some(FailClass::Other(_))
8069        ));
8070    }
8071
8072    #[test]
8073    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8074        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8075        let tried = BTreeSet::from(["beta".to_owned()]);
8076        // beta sits at index 1; the next candidate is gamma, never alpha —
8077        // which is very likely a different candidate slot's own agent.
8078        let next = next_untried_in_roster(&roster, 1, &tried);
8079        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8080    }
8081
8082    #[test]
8083    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8084        let roster = vec![spec("alpha"), spec("beta")];
8085        let tried = BTreeSet::from(["beta".to_owned()]);
8086        // beta is the roster's last entry: nothing follows it, and alpha —
8087        // earlier in the roster, almost certainly a different candidate
8088        // slot's own agent — must not be reached by wrapping back to it.
8089        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8090    }
8091
8092    #[test]
8093    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8094        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8095        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8096        // beta (index 1) and gamma (index 2, the only entry after it) have
8097        // both been tried; alpha (index 0) never has, but it comes before
8098        // beta's own position, so there is nothing further for this seat.
8099        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8100    }
8101
8102    #[test]
8103    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8104        let roster = vec![spec("a"), spec("a"), spec("b")];
8105        let tried = BTreeSet::from(["a".to_owned()]);
8106        let next = next_untried_in_roster(&roster, 0, &tried);
8107        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8108    }
8109
8110    #[test]
8111    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8112        let roster = vec![spec("a"), spec("b")];
8113        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8114        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8115    }
8116
8117    #[test]
8118    fn remove_if_empty_only_ever_takes_a_bare_directory() {
8119        let dir = tempfile::tempdir().unwrap();
8120        let bay = dir.path().join("ffff");
8121
8122        // Not there yet: nothing to do, nothing to panic on.
8123        remove_if_empty(&bay);
8124        assert!(!bay.exists());
8125
8126        // Something still inside - the winner's worktree, or a stray file -
8127        // keeps the directory standing.
8128        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8129        remove_if_empty(&bay);
8130        assert!(bay.exists(), "non-empty directory must survive");
8131
8132        // Once the last entry is gone, so is the directory itself.
8133        std::fs::remove_dir(bay.join("cand-A")).unwrap();
8134        remove_if_empty(&bay);
8135        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8136    }
8137
8138    // `round_is_clean` is the exact decision this task fixed: a round with a
8139    // seat that never answered must not read the same as a round every seat
8140    // actually reviewed. These are deterministic and process-free by design —
8141    // the equivalent end-to-end check (a real reviewer timing out under a
8142    // live graph run) is a genuine race against wall-clock contention, and a
8143    // spawn slow enough to blow even a generous budget under a loaded test
8144    // run must not turn this specific regression check flaky.
8145
8146    #[test]
8147    fn a_full_panel_that_found_nothing_is_clean() {
8148        assert!(round_is_clean(
8149            0,
8150            true,
8151            2,
8152            2,
8153            0,
8154            IncompleteReviewPolicy::Block
8155        ));
8156    }
8157
8158    #[test]
8159    fn a_missing_seat_is_never_clean_under_the_default_policy() {
8160        assert!(!round_is_clean(
8161            0,
8162            true,
8163            1,
8164            2,
8165            0,
8166            IncompleteReviewPolicy::Block
8167        ));
8168    }
8169
8170    #[test]
8171    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8172        assert!(!round_is_clean(
8173            1,
8174            true,
8175            1,
8176            2,
8177            0,
8178            IncompleteReviewPolicy::Warn
8179        ));
8180    }
8181
8182    #[test]
8183    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8184        assert!(round_is_clean(
8185            0,
8186            true,
8187            1,
8188            2,
8189            0,
8190            IncompleteReviewPolicy::Warn
8191        ));
8192    }
8193
8194    #[test]
8195    fn a_full_panel_with_an_open_finding_is_not_clean() {
8196        assert!(!round_is_clean(
8197            1,
8198            true,
8199            2,
8200            2,
8201            0,
8202            IncompleteReviewPolicy::Block
8203        ));
8204    }
8205
8206    #[test]
8207    fn a_full_panel_with_a_red_e2e_is_not_clean() {
8208        assert!(!round_is_clean(
8209            0,
8210            false,
8211            2,
8212            2,
8213            0,
8214            IncompleteReviewPolicy::Block
8215        ));
8216    }
8217
8218    // The stall this task closes: under the default `block` policy, a seat
8219    // missing only because it was rate limited must not force a wait for a
8220    // session limit that will not lift by the next round. `round_is_clean`
8221    // is where that quorum carve-out lives; the review loop around it never
8222    // changes what a reviewer's vote or a finding's severity means.
8223
8224    #[test]
8225    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8226        // 1 of 2 answered, and the one missing was quota'd — the exact
8227        // "review-2 rate limited (quota)" shape from the field report.
8228        assert!(round_is_clean(
8229            0,
8230            true,
8231            1,
8232            2,
8233            1,
8234            IncompleteReviewPolicy::Block
8235        ));
8236    }
8237
8238    #[test]
8239    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8240        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
8241        // not a quota loss (`quota_missing` stays 0) — worth another try.
8242        assert!(!round_is_clean(
8243            0,
8244            true,
8245            1,
8246            2,
8247            0,
8248            IncompleteReviewPolicy::Block
8249        ));
8250    }
8251
8252    #[test]
8253    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8254        assert!(!round_is_clean(
8255            1,
8256            true,
8257            1,
8258            2,
8259            1,
8260            IncompleteReviewPolicy::Block
8261        ));
8262        assert!(!round_is_clean(
8263            0,
8264            false,
8265            1,
8266            2,
8267            1,
8268            IncompleteReviewPolicy::Block
8269        ));
8270    }
8271
8272    #[test]
8273    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8274        // Every seat quota'd, nobody answered: there is no panel to decide
8275        // on, so this must fall through to the existing block-and-retry
8276        // fallback rather than call an unreviewed patch clean.
8277        assert!(!round_is_clean(
8278            0,
8279            true,
8280            0,
8281            2,
8282            2,
8283            IncompleteReviewPolicy::Block
8284        ));
8285    }
8286
8287    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8288        CommandOutcome {
8289            command: "test".to_owned(),
8290            code,
8291            output_tail: String::new(),
8292            duration_ms: 0,
8293            resource_blocked,
8294        }
8295    }
8296
8297    #[test]
8298    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8299        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8300        assert!(
8301            !verify_inconclusive(&[outcome(Some(1), false)]),
8302            "an ordinary failure is still evidence about the patch"
8303        );
8304        assert!(verify_inconclusive(&[outcome(None, true)]));
8305        assert!(
8306            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8307            "one inconclusive outcome taints the whole batch"
8308        );
8309        assert!(!verify_inconclusive(&[]));
8310    }
8311
8312    #[tokio::test]
8313    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
8314        // Alive for the first two checks, then dead - confirms the loop
8315        // actually re-polls rather than deciding once and sleeping out the
8316        // ceiling regardless.
8317        let calls = std::sync::atomic::AtomicUsize::new(0);
8318        let started = Instant::now();
8319        wait_for_pids_with(
8320            &[123],
8321            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
8322            Duration::from_millis(5),
8323            Duration::from_secs(5),
8324        )
8325        .await;
8326        assert!(
8327            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
8328            "must keep checking rather than deciding on the first answer"
8329        );
8330        assert!(
8331            started.elapsed() < Duration::from_secs(1),
8332            "must return the moment it is confirmed dead, not wait out the ceiling"
8333        );
8334    }
8335
8336    #[tokio::test]
8337    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
8338        let started = Instant::now();
8339        wait_for_pids_with(
8340            &[123],
8341            |_| true, // never reports dead
8342            Duration::from_millis(5),
8343            Duration::from_millis(30),
8344        )
8345        .await;
8346        let elapsed = started.elapsed();
8347        assert!(
8348            elapsed >= Duration::from_millis(30),
8349            "must not give up before its own ceiling: {elapsed:?}"
8350        );
8351        assert!(
8352            elapsed < Duration::from_secs(1),
8353            "must not wait past its own ceiling either: {elapsed:?}"
8354        );
8355    }
8356
8357    #[tokio::test]
8358    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
8359        let started = Instant::now();
8360        wait_for_pids_with(
8361            &[],
8362            |_| true,
8363            Duration::from_secs(5),
8364            Duration::from_secs(5),
8365        )
8366        .await;
8367        assert!(
8368            started.elapsed() < Duration::from_millis(200),
8369            "an empty pid list has nothing to confirm"
8370        );
8371    }
8372
8373    // `review_conclusion` is the exact decision the review hand-off task
8374    // fixed: a round budget spent (or a tree that stopped moving) must not
8375    // collapse into `Blocked` regardless of what verification actually
8376    // said. Deterministic and process-free for the same reason the
8377    // `round_is_clean` family above is.
8378    fn review_round(
8379        clean: bool,
8380        blocking: usize,
8381        answered: usize,
8382        expected: usize,
8383        progressed: bool,
8384        e2e_ok: bool,
8385    ) -> ReviewRound {
8386        ReviewRound {
8387            round: 1,
8388            head: "h".to_owned(),
8389            verified_head: None,
8390            verified_at: None,
8391            reviews: Vec::new(),
8392            e2e: vec![CommandOutcome {
8393                command: "test".to_owned(),
8394                code: Some(if e2e_ok { 0 } else { 1 }),
8395                output_tail: String::new(),
8396                duration_ms: 0,
8397                resource_blocked: false,
8398            }],
8399            verify_retried: false,
8400            e2e_deferred: false,
8401            e2e_defer_reason: None,
8402            fix: None,
8403            blocking,
8404            answered,
8405            expected,
8406            clean,
8407            progressed,
8408            vote_split: false,
8409            reconsideration: Vec::new(),
8410            verdict: None,
8411        }
8412    }
8413
8414    #[test]
8415    fn review_conclusion_is_none_when_nothing_has_run() {
8416        assert_eq!(review_conclusion(&[], 3), None);
8417    }
8418
8419    #[test]
8420    fn review_conclusion_is_none_while_rounds_remain() {
8421        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
8422        assert_eq!(review_conclusion(&rounds, 3), None);
8423    }
8424
8425    #[test]
8426    fn review_conclusion_is_gating_once_a_round_is_clean() {
8427        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
8428        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
8429    }
8430
8431    #[test]
8432    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
8433        let rounds = vec![
8434            review_round(false, 1, 2, 2, true, true),
8435            review_round(false, 1, 2, 2, true, true),
8436        ];
8437        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
8438    }
8439
8440    #[test]
8441    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
8442        let rounds = vec![
8443            review_round(false, 1, 2, 2, true, true),
8444            review_round(false, 1, 2, 2, true, false),
8445        ];
8446        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
8447    }
8448
8449    #[test]
8450    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
8451        // Magi never got a command to run against this round's own head — a
8452        // resource-blocked attempt, not a red one — so this must never
8453        // settle on `Blocked` the way a genuine e2e failure would. `None`
8454        // here is what tells `Runner::review_loop` to retry the check
8455        // itself rather than trust this cheap recomputation with a verdict
8456        // it cannot actually produce.
8457        let mut blocked = review_round(false, 1, 2, 2, true, false);
8458        blocked.e2e[0].resource_blocked = true;
8459        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
8460        assert_eq!(review_conclusion(&rounds, 2), None);
8461    }
8462
8463    #[test]
8464    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
8465        // Missing input, not a verified tree — never a hand-off candidate.
8466        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
8467        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
8468    }
8469
8470    #[test]
8471    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
8472        let rounds = vec![
8473            review_round(false, 1, 2, 2, false, true),
8474            review_round(false, 1, 2, 2, false, true),
8475        ];
8476        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
8477    }
8478
8479    fn secs(n: u64) -> Duration {
8480        Duration::from_secs(n)
8481    }
8482
8483    /// A throwaway repo with one commit on `main`, for tests that need `merge`
8484    /// to make real (and, if it runs at all, real*ly fail*) git calls.
8485    fn init_repo(dir: &Path) {
8486        let run = |args: &[&str]| {
8487            let out = std::process::Command::new("git")
8488                .args(args)
8489                .current_dir(dir)
8490                .quiet()
8491                .output()
8492                .expect("spawn git");
8493            assert!(
8494                out.status.success(),
8495                "git {args:?} failed: {}",
8496                String::from_utf8_lossy(&out.stderr)
8497            );
8498        };
8499        run(&["init", "-b", "main"]);
8500        run(&["config", "user.name", "magi test"]);
8501        run(&["config", "user.email", "magi@example.com"]);
8502        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
8503        run(&["add", "-A"]);
8504        run(&["commit", "-m", "init"]);
8505    }
8506
8507    // `settle_questions` is what closes the ghost the phone showed: a run's
8508    // seat asked something, the run then ended, and nothing was left to
8509    // abandon the question it left `open`. `HOME` is a process-wide
8510    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
8511    // first time it runs in the binary — every test below still reaches the
8512    // same directory whichever call won, and each gets its own run id from
8513    // `RunState::new`, so they never collide there.
8514    fn ask_test_home() {
8515        crate::run::set_home(std::env::temp_dir().join("magi-graph-ask-tests-home"));
8516    }
8517
8518    /// A minimal, git-free `Runner` at a given status — `settle_questions`
8519    /// reads nothing else off it.
8520    fn runner_at(status: RunStatus) -> Runner {
8521        let mut state = RunState::new(
8522            PathBuf::from("/nonexistent/repo"),
8523            "main".to_owned(),
8524            "deadbeef".to_owned(),
8525            "task".to_owned(),
8526            Config::default(),
8527        );
8528        state.status = status;
8529        Runner {
8530            state,
8531            roles: ResolvedRoles {
8532                implementers: Vec::new(),
8533                judges: Vec::new(),
8534                reviewers: Vec::new(),
8535                fixer: None,
8536                conductor: conductor(),
8537                implementer_roster: Vec::new(),
8538                judge_roster: Vec::new(),
8539                reviewer_roster: Vec::new(),
8540            },
8541            sem: Arc::new(Semaphore::new(1)),
8542            pause: Pause::new(),
8543            interrupt: Pause::new(),
8544        }
8545    }
8546
8547    /// `park_here` folding in the reason `Pause::park_because` recorded -
8548    /// this is what lets an operator reading a run's events tell an
8549    /// interrupt-driven park from an ordinary shutdown park.
8550    #[test]
8551    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
8552        crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8553        let mut runner = runner_at(RunStatus::Implementing);
8554        let interrupt = Pause::new();
8555        runner.watch_interrupt(interrupt.clone());
8556
8557        interrupt.park_because("task a1b2 asked to run first");
8558
8559        assert!(runner.park_here().expect("park_here"));
8560        assert!(runner.state.parked);
8561        let last = runner.state.events.last().expect("a park event");
8562        assert_eq!(last.node, "park");
8563        assert!(
8564            last.message.contains("task a1b2 asked to run first"),
8565            "expected the interrupt reason in {:?}",
8566            last.message
8567        );
8568    }
8569
8570    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
8571    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
8572    /// cleared) must not make a *different* run - one only watching its own,
8573    /// unshared interrupt `Pause` - see itself as parked. If a future change
8574    /// ever collapsed these back into one handle, the interrupt scheduler
8575    /// would park every run for the rest of the daemon's life, not just the
8576    /// one it meant to interrupt.
8577    #[test]
8578    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
8579        crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8580        let mut runner = runner_at(RunStatus::Implementing);
8581        let shutdown = Pause::new();
8582        runner.on_pause(shutdown.clone());
8583        let interrupt = Pause::new();
8584        runner.watch_interrupt(interrupt.clone());
8585
8586        // Nobody has asked for anything yet.
8587        assert!(!runner.park_here().expect("park_here"));
8588        assert!(!runner.state.parked);
8589
8590        // Only the interrupt handle fires; the shutdown handle stays clear.
8591        interrupt.park_because("test");
8592        assert!(!shutdown.parked());
8593        assert!(runner.park_here().expect("park_here"));
8594    }
8595
8596    /// The property every prior attempt at this feature failed to pin down:
8597    /// asking a run to park while one of its nodes has a real, in-flight
8598    /// async operation running (an agent call, in production) must not cut
8599    /// that operation short. `park_here` is only ever consulted *between*
8600    /// `execute`'s node calls - see its own doc - so nothing inside a node
8601    /// can observe a park request until the node itself returns. This proves
8602    /// that structurally, with real `tokio` concurrency and a channel
8603    /// handshake (never a sleep, which would only prove "usually", not
8604    /// "cannot"): the "node" below reports that it has genuinely started,
8605    /// and only then is the park requested; the node still has to be told to
8606    /// finish before `park_here` is ever called, exactly mirroring every
8607    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
8608    /// in `execute`.
8609    #[tokio::test]
8610    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
8611        crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8612        let mut runner = runner_at(RunStatus::Implementing);
8613        let interrupt = Pause::new();
8614        runner.watch_interrupt(interrupt.clone());
8615
8616        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
8617        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
8618
8619        // Stands in for one node's in-flight agent call: it proves it has
8620        // genuinely started, then blocks - exactly as a spawned CLI process
8621        // does - until told to finish.
8622        let node = async move {
8623            started_tx.send(()).expect("send started");
8624            finish_rx.await.expect("recv finish");
8625            "node finished"
8626        };
8627
8628        let interrupter = async move {
8629            started_rx.await.expect("recv started");
8630            // The call is now genuinely in flight. Ask it to park.
8631            interrupt.park_because("higher-priority task waiting");
8632            // Nothing the node does can observe this yet - there is no
8633            // check inside it, by construction - so let the executor run
8634            // anything pending and then let the node finish on its own.
8635            tokio::task::yield_now().await;
8636            finish_tx.send(()).expect("send finish");
8637        };
8638
8639        let (node_result, ()) = tokio::join!(node, interrupter);
8640        assert_eq!(
8641            node_result, "node finished",
8642            "the in-flight call ran to completion"
8643        );
8644
8645        // Only now, at the boundary the real `execute` would check right
8646        // after this node, does the park take effect.
8647        assert!(runner.park_here().expect("park_here"));
8648        assert!(runner.state.parked);
8649    }
8650
8651    /// A run parked mid-competition carries every field it had accumulated
8652    /// through the exact same disk round-trip an ordinary resume uses -
8653    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
8654    /// Nothing about parking for an interrupt is a special case of that path;
8655    /// this is what proves it rather than assuming it.
8656    #[test]
8657    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
8658        crate::run::set_home(std::env::temp_dir().join("magi-graph-interrupt-tests-home"));
8659        let mut runner = runner_at(RunStatus::Judging);
8660        // `Runner::resume` re-resolves roles from the saved config, which
8661        // refuses an empty roster - give it the same minimal one `conductor`
8662        // itself uses.
8663        runner.state.config.agents = vec![conductor()];
8664        runner.state.candidates = vec![Candidate {
8665            index: 0,
8666            label: 'A',
8667            agent: "alpha".to_owned(),
8668            branch: "magi/x/A".to_owned(),
8669            worktree: PathBuf::from("/nonexistent/worktree"),
8670            summary: "did the thing".to_owned(),
8671            stat: "1 file changed".to_owned(),
8672            files: 1,
8673            commits: 1,
8674            empty: false,
8675            failed: None,
8676            verified_noop: None,
8677            duration_ms: 1234,
8678            folded: false,
8679        }];
8680        let run_id = runner.state.id.clone();
8681
8682        let interrupt = Pause::new();
8683        runner.watch_interrupt(interrupt.clone());
8684        interrupt.park_because("task c3d4 asked to run first");
8685        assert!(runner.park_here().expect("park_here"));
8686
8687        let resumed = Runner::resume(&run_id).expect("resume");
8688        assert_eq!(resumed.state.candidates.len(), 1);
8689        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
8690        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
8691        assert_eq!(resumed.state.status, runner.state.status);
8692        assert!(
8693            resumed.state.parked,
8694            "still parked until `execute` actually walks the graph again"
8695        );
8696        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
8697    }
8698
8699    /// A fresh open question on `run`, stored and handed back for assertions.
8700    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
8701        let mut q = ask::Question::new(
8702            run.to_owned(),
8703            "implement".to_owned(),
8704            "impl-A".to_owned(),
8705            "Which storage backend should the cache use?".to_owned(),
8706            String::new(),
8707            vec!["SQLite".to_owned(), "Redis".to_owned()],
8708        );
8709        store.put(&mut q).unwrap();
8710        q
8711    }
8712
8713    #[test]
8714    fn a_failed_runs_open_question_is_abandoned() {
8715        ask_test_home();
8716        let store = ask::Questions::open();
8717        let mut runner = runner_at(RunStatus::Failed);
8718        let run = runner.state.id.clone();
8719        let q = ask_open_question(&store, &run);
8720
8721        runner.settle_questions();
8722
8723        let back = store.get(&q.id).unwrap();
8724        assert!(
8725            !back.status.open(),
8726            "the seat that asked died with the run; nobody is left to read an answer"
8727        );
8728        assert!(
8729            back.detail.contains(&run) && back.detail.contains("failed"),
8730            "the reason names what the run became, not just that it is gone: {}",
8731            back.detail
8732        );
8733    }
8734
8735    #[test]
8736    fn a_merged_runs_open_question_is_abandoned_too() {
8737        ask_test_home();
8738        let store = ask::Questions::open();
8739        // A run that finishes cleanly still leaves nobody to read an answer -
8740        // this is not only a failure-path cleanup.
8741        for status in [RunStatus::Merged, RunStatus::Ready] {
8742            let mut runner = runner_at(status);
8743            let run = runner.state.id.clone();
8744            let q = ask_open_question(&store, &run);
8745
8746            runner.settle_questions();
8747
8748            let back = store.get(&q.id).unwrap();
8749            assert!(
8750                !back.status.open(),
8751                "{status:?} run's question must not outlive the run"
8752            );
8753        }
8754    }
8755
8756    #[test]
8757    fn a_still_resumable_runs_open_question_is_left_alone() {
8758        ask_test_home();
8759        let store = ask::Questions::open();
8760        // `Blocked` and `Stalled` can still be resumed — the candidates, the
8761        // review round and the seat sessions are all still on disk — so a
8762        // question asked mid-round may yet get a real answer from a real
8763        // resume. Sweeping it here would be exactly the failure mode this
8764        // whole feature exists to avoid on the other side.
8765        for status in [RunStatus::Blocked, RunStatus::Stalled] {
8766            let mut runner = runner_at(status);
8767            let run = runner.state.id.clone();
8768            let q = ask_open_question(&store, &run);
8769
8770            runner.settle_questions();
8771
8772            let back = store.get(&q.id).unwrap();
8773            assert!(
8774                back.status.open(),
8775                "{status:?} is still alive; the question must still be waiting"
8776            );
8777        }
8778    }
8779
8780    #[test]
8781    fn settle_questions_never_touches_an_already_answered_question() {
8782        ask_test_home();
8783        let store = ask::Questions::open();
8784        let mut runner = runner_at(RunStatus::Failed);
8785        let run = runner.state.id.clone();
8786        let mut q = ask_open_question(&store, &run);
8787        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
8788            .unwrap();
8789        store.put(&mut q).unwrap();
8790
8791        // Called twice, the way a crash-recovered daemon reclaim and the
8792        // graph's own cleanup both can for the same run — `abandon_for_run`
8793        // only ever touches what is still open, so this must be inert both
8794        // times, not merely the second.
8795        runner.settle_questions();
8796        runner.settle_questions();
8797
8798        let back = store.get(&q.id).unwrap();
8799        assert_eq!(
8800            back.status,
8801            ask::QuestionStatus::Answered,
8802            "a real answer is a decision on record, never overwritten by a sweep"
8803        );
8804    }
8805
8806    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
8807    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
8808    /// without merging, whose winner is still the operator's answer to read.
8809    /// Nothing previously called `fold_run` itself with a real `tally`, so
8810    /// this is the first test to pin down the one distinction the whole
8811    /// automatic-fold feature depends on: the winner's worktree and branch
8812    /// must survive, everything else sharing the run's worktree bay - a
8813    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
8814    /// second sweep treats every non-winner directory under the bay alike -
8815    /// must not.
8816    #[tokio::test]
8817    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
8818        crate::run::set_home(std::env::temp_dir().join("magi-graph-fold-run-tests-home"));
8819        let tmp = tempfile::tempdir().expect("tempdir");
8820        let repo = tmp.path().join("repo");
8821        std::fs::create_dir_all(&repo).unwrap();
8822        init_repo(&repo);
8823
8824        let mut config = Config::default();
8825        config.graph.worktree_root = Some(tmp.path().join("wt"));
8826
8827        let mut state = RunState::new(
8828            repo.clone(),
8829            "main".to_owned(),
8830            "deadbeef".to_owned(),
8831            "task".to_owned(),
8832            config,
8833        );
8834        let root = state.worktree_root();
8835        let wt_a = root.join("cand-A");
8836        let wt_b = root.join("cand-B");
8837        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
8838            .await
8839            .expect("worktree A");
8840        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
8841            .await
8842            .expect("worktree B");
8843
8844        state.candidates = vec![
8845            Candidate {
8846                index: 0,
8847                label: 'A',
8848                agent: "alpha".to_owned(),
8849                branch: "magi/x/A".to_owned(),
8850                worktree: wt_a.clone(),
8851                summary: String::new(),
8852                stat: String::new(),
8853                files: 0,
8854                commits: 0,
8855                empty: false,
8856                failed: None,
8857                verified_noop: None,
8858                duration_ms: 0,
8859                folded: false,
8860            },
8861            Candidate {
8862                index: 1,
8863                label: 'B',
8864                agent: "beta".to_owned(),
8865                branch: "magi/x/B".to_owned(),
8866                worktree: wt_b.clone(),
8867                summary: String::new(),
8868                stat: String::new(),
8869                files: 0,
8870                commits: 0,
8871                empty: false,
8872                failed: None,
8873                verified_noop: None,
8874                duration_ms: 0,
8875                folded: false,
8876            },
8877        ];
8878        state.tally = Some(Tally {
8879            first_choice: BTreeMap::from([('A', 1)]),
8880            borda: BTreeMap::new(),
8881            winner: 'A',
8882            rankings: 1,
8883            unanimous_initial: true,
8884            deliberated: false,
8885            changed_votes: 0,
8886            unanimous_final: true,
8887            tie_break: None,
8888            judges: 1,
8889            present: 1,
8890            quorum: 1,
8891            met_quorum: true,
8892            uncontested: None,
8893        });
8894        state.status = RunStatus::Ready;
8895
8896        fold_run(&mut state, false, &crate::run::home())
8897            .await
8898            .expect("fold_run");
8899
8900        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
8901        assert!(
8902            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
8903            "the unmerged winner's branch survives"
8904        );
8905        assert!(
8906            !state.candidates[0].folded,
8907            "the winner is not marked folded"
8908        );
8909
8910        assert!(!wt_b.exists(), "the loser's worktree is removed");
8911        assert!(
8912            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
8913            "the loser's branch is removed"
8914        );
8915        assert!(state.candidates[1].folded, "the loser is marked folded");
8916    }
8917
8918    /// A branch handed to a later run is that run's (and its pull request's):
8919    /// folding the run that released it must not delete it.
8920    #[tokio::test]
8921    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
8922        let tmp = tempfile::tempdir().expect("tempdir");
8923        let repo = tmp.path().join("repo");
8924        std::fs::create_dir_all(&repo).unwrap();
8925        init_repo(&repo);
8926        let home = tmp.path().join("home");
8927
8928        let mut config = Config::default();
8929        config.graph.worktree_root = Some(tmp.path().join("wt"));
8930        let mut state = RunState::new(
8931            repo.clone(),
8932            "main".to_owned(),
8933            "deadbeef".to_owned(),
8934            "task".to_owned(),
8935            config,
8936        );
8937        // The worktree is already gone (released); the branch survives.
8938        git::git(&repo, &["branch", "magi/x/A", "main"])
8939            .await
8940            .expect("branch");
8941        state.candidates = vec![Candidate {
8942            index: 0,
8943            label: 'A',
8944            agent: "alpha".to_owned(),
8945            branch: "magi/x/A".to_owned(),
8946            worktree: state.worktree_root().join("cand-A"),
8947            summary: String::new(),
8948            stat: String::new(),
8949            files: 0,
8950            commits: 0,
8951            empty: false,
8952            failed: None,
8953            verified_noop: None,
8954            duration_ms: 0,
8955            folded: true,
8956        }];
8957        state.released_to = Some("20260901-000000-new1".to_owned());
8958        state.released_branches = vec!["magi/x/A".to_owned()];
8959
8960        fold_run(&mut state, true, &home).await.expect("fold_run");
8961
8962        assert!(
8963            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
8964            "the handed-over branch survives a fold"
8965        );
8966    }
8967
8968    /// A winner with nothing ahead of the base is caught before `gh` is ever
8969    /// asked for a pull request, and the message carries what the task's
8970    /// references resolved to.
8971    #[tokio::test]
8972    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
8973        let tmp = tempfile::tempdir().expect("tempdir");
8974        let repo = tmp.path().join("repo");
8975        std::fs::create_dir_all(&repo).unwrap();
8976        init_repo(&repo);
8977        let run = |args: &[&str]| {
8978            let out = std::process::Command::new("git")
8979                .quiet()
8980                .args(args)
8981                .current_dir(&repo)
8982                .output()
8983                .expect("spawn git");
8984            assert!(out.status.success(), "git {args:?}");
8985        };
8986        run(&["branch", "magi/x/A"]);
8987        run(&["checkout", "-q", "-b", "magi/x/B"]);
8988        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
8989        run(&["add", "-A"]);
8990        run(&["commit", "-q", "-m", "work"]);
8991        run(&["checkout", "-q", "main"]);
8992
8993        let mut state = RunState::new(
8994            repo.clone(),
8995            "main".to_owned(),
8996            "deadbeef".to_owned(),
8997            "task".to_owned(),
8998            Config::default(),
8999        );
9000        state.seeds = vec![refs::Seed {
9001            token: "magi/27b2/A".to_owned(),
9002            kind: refs::SeedKind::Unresolved,
9003            sha: String::new(),
9004            branch: true,
9005            detail: "no branch or commit named magi/27b2/A".to_owned(),
9006        }];
9007
9008        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9009        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9010        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9011        let detail = empty_candidate_detail(&state, "main");
9012        assert!(detail.starts_with("empty candidate"), "{detail}");
9013        assert!(detail.contains("magi/27b2/A"), "{detail}");
9014    }
9015
9016    /// `status == Ready` used to be read as "this is the harmless
9017    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
9018    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
9019    /// run's PR was closed without merging — and reentering `merge` with
9020    /// `mode` still `Pr` does not know the difference, so it pushed and
9021    /// opened a second pull request. `mode == Local` reproduces the same
9022    /// blind spot without a network call: reentry must not attempt another
9023    /// git merge once this node has already recorded an outcome.
9024    #[tokio::test]
9025    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9026        let tmp = tempfile::tempdir().expect("tempdir");
9027        let repo = tmp.path().join("repo");
9028        std::fs::create_dir_all(&repo).unwrap();
9029        init_repo(&repo);
9030
9031        let mut config = Config::default();
9032        config.merge.mode = MergeMode::Local;
9033
9034        let mut state = RunState::new(
9035            repo.clone(),
9036            "main".to_owned(),
9037            "deadbeef".to_owned(),
9038            "task".to_owned(),
9039            config,
9040        );
9041        state.candidates = vec![Candidate {
9042            index: 0,
9043            label: 'A',
9044            agent: "alpha".to_owned(),
9045            branch: "does-not-exist".to_owned(),
9046            worktree: repo.clone(),
9047            summary: String::new(),
9048            stat: String::new(),
9049            files: 0,
9050            commits: 0,
9051            empty: false,
9052            failed: None,
9053            verified_noop: None,
9054            duration_ms: 0,
9055            folded: false,
9056        }];
9057        state.tally = Some(Tally {
9058            first_choice: BTreeMap::from([('A', 1)]),
9059            borda: BTreeMap::new(),
9060            winner: 'A',
9061            rankings: 1,
9062            unanimous_initial: true,
9063            deliberated: false,
9064            changed_votes: 0,
9065            unanimous_final: true,
9066            tie_break: None,
9067            judges: 0,
9068            present: 0,
9069            quorum: 0,
9070            met_quorum: true,
9071            uncontested: Some("only candidate A produced a change".to_owned()),
9072        });
9073        state.reviews = vec![ReviewRound {
9074            round: 1,
9075            head: "deadbeef".to_owned(),
9076            verified_head: None,
9077            verified_at: None,
9078            reviews: Vec::new(),
9079            e2e: Vec::new(),
9080            fix: None,
9081            blocking: 0,
9082            answered: 0,
9083            expected: 0,
9084            clean: true,
9085            verify_retried: false,
9086            e2e_deferred: false,
9087            e2e_defer_reason: None,
9088            progressed: false,
9089            vote_split: false,
9090            reconsideration: Vec::new(),
9091            verdict: None,
9092        }];
9093        state.gate = vec![CommandOutcome {
9094            command: "test".to_owned(),
9095            code: Some(0),
9096            output_tail: String::new(),
9097            duration_ms: 0,
9098            resource_blocked: false,
9099        }];
9100        state.gate_ran = true;
9101        // Reached its conclusion already — e.g. `land` closing the PR without
9102        // merging it, which (like the honest `MergeMode::None` path) leaves
9103        // `status` at `Ready`. The recorded outcome is what actually marks
9104        // this node done.
9105        state.status = RunStatus::Ready;
9106        state.merge = Some(MergeOutcome {
9107            mode: MergeMode::Local,
9108            ok: false,
9109            detail: "already concluded".to_owned(),
9110            empty: false,
9111        });
9112
9113        let mut runner = Runner {
9114            state,
9115            roles: ResolvedRoles {
9116                implementers: Vec::new(),
9117                judges: Vec::new(),
9118                reviewers: Vec::new(),
9119                fixer: None,
9120                conductor: conductor(),
9121                implementer_roster: Vec::new(),
9122                judge_roster: Vec::new(),
9123                reviewer_roster: Vec::new(),
9124            },
9125            sem: Arc::new(Semaphore::new(1)),
9126            pause: Pause::new(),
9127            interrupt: Pause::new(),
9128        };
9129
9130        runner.merge().await.expect("merge");
9131
9132        assert_eq!(
9133            runner.state.status,
9134            RunStatus::Ready,
9135            "a concluded run's status must not change on reentry"
9136        );
9137        assert_eq!(
9138            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9139            Some("already concluded"),
9140            "merge must not run again once the node already recorded an outcome"
9141        );
9142    }
9143
9144    /// `gate` leaves `state.gate_ran` false both before it has ever run and
9145    /// when its last attempt was resource-blocked (the shared build cache
9146    /// could not be acquired or confirmed fresh in time - see
9147    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
9148    /// `Vec` this also leaves behind used to read as "nothing failed" and let
9149    /// a run merge a tree the gate never actually checked - exactly the case
9150    /// a contended cache produces on every retry until it clears. `merge`
9151    /// must refuse until `gate` has actually recorded an attempt.
9152    #[tokio::test]
9153    async fn merge_refuses_a_gate_that_has_not_actually_run() {
9154        let tmp = tempfile::tempdir().expect("tempdir");
9155        let repo = tmp.path().join("repo");
9156        std::fs::create_dir_all(&repo).unwrap();
9157        init_repo(&repo);
9158
9159        let mut config = Config::default();
9160        config.merge.mode = MergeMode::Local;
9161
9162        let mut state = RunState::new(
9163            repo.clone(),
9164            "main".to_owned(),
9165            "deadbeef".to_owned(),
9166            "task".to_owned(),
9167            config,
9168        );
9169        state.candidates = vec![Candidate {
9170            index: 0,
9171            label: 'A',
9172            agent: "alpha".to_owned(),
9173            branch: "does-not-exist".to_owned(),
9174            worktree: repo.clone(),
9175            summary: String::new(),
9176            stat: String::new(),
9177            files: 0,
9178            commits: 0,
9179            empty: false,
9180            failed: None,
9181            verified_noop: None,
9182            duration_ms: 0,
9183            folded: false,
9184        }];
9185        state.tally = Some(Tally {
9186            first_choice: BTreeMap::from([('A', 1)]),
9187            borda: BTreeMap::new(),
9188            winner: 'A',
9189            rankings: 1,
9190            unanimous_initial: true,
9191            deliberated: false,
9192            changed_votes: 0,
9193            unanimous_final: true,
9194            tie_break: None,
9195            judges: 0,
9196            present: 0,
9197            quorum: 0,
9198            met_quorum: true,
9199            uncontested: Some("only candidate A produced a change".to_owned()),
9200        });
9201        state.reviews = vec![ReviewRound {
9202            round: 1,
9203            head: "deadbeef".to_owned(),
9204            verified_head: None,
9205            verified_at: None,
9206            reviews: Vec::new(),
9207            e2e: Vec::new(),
9208            fix: None,
9209            blocking: 0,
9210            answered: 0,
9211            expected: 0,
9212            clean: true,
9213            verify_retried: false,
9214            e2e_deferred: false,
9215            e2e_defer_reason: None,
9216            progressed: false,
9217            vote_split: false,
9218            reconsideration: Vec::new(),
9219            verdict: None,
9220        }];
9221        // The point: `gate` has not recorded anything yet.
9222        state.gate = Vec::new();
9223        state.gate_ran = false;
9224        state.status = RunStatus::Gating;
9225
9226        let mut runner = Runner {
9227            state,
9228            roles: ResolvedRoles {
9229                implementers: Vec::new(),
9230                judges: Vec::new(),
9231                reviewers: Vec::new(),
9232                fixer: None,
9233                conductor: conductor(),
9234                implementer_roster: Vec::new(),
9235                judge_roster: Vec::new(),
9236                reviewer_roster: Vec::new(),
9237            },
9238            sem: Arc::new(Semaphore::new(1)),
9239            pause: Pause::new(),
9240            interrupt: Pause::new(),
9241        };
9242
9243        runner.merge().await.expect("merge");
9244
9245        assert!(
9246            runner.state.merge.is_none(),
9247            "an empty gate must never be read as a passing one: {:?}",
9248            runner.state.merge
9249        );
9250    }
9251
9252    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
9253    /// commands configured and `merge.mode` is `none` (a review-only run).
9254    /// `gate` must still record a real attempt — zero commands, vacuously
9255    /// passed — rather than leaving `state.gate` empty in a way `merge`
9256    /// cannot tell apart from "never ran"; otherwise the run reaches
9257    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
9258    #[tokio::test]
9259    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9260        let tmp = tempfile::tempdir().expect("tempdir");
9261        let repo = tmp.path().join("repo");
9262        std::fs::create_dir_all(&repo).unwrap();
9263        init_repo(&repo);
9264
9265        // Default config: `verify.gate` empty, `merge.mode` is `none`.
9266        let config = Config::default();
9267
9268        let mut state = RunState::new(
9269            repo.clone(),
9270            "main".to_owned(),
9271            "deadbeef".to_owned(),
9272            "task".to_owned(),
9273            config,
9274        );
9275        state.candidates = vec![Candidate {
9276            index: 0,
9277            label: 'A',
9278            agent: "alpha".to_owned(),
9279            branch: "does-not-exist".to_owned(),
9280            worktree: repo.clone(),
9281            summary: String::new(),
9282            stat: String::new(),
9283            files: 0,
9284            commits: 0,
9285            empty: false,
9286            failed: None,
9287            verified_noop: None,
9288            duration_ms: 0,
9289            folded: false,
9290        }];
9291        state.tally = Some(Tally {
9292            first_choice: BTreeMap::from([('A', 1)]),
9293            borda: BTreeMap::new(),
9294            winner: 'A',
9295            rankings: 1,
9296            unanimous_initial: true,
9297            deliberated: false,
9298            changed_votes: 0,
9299            unanimous_final: true,
9300            tie_break: None,
9301            judges: 0,
9302            present: 0,
9303            quorum: 0,
9304            met_quorum: true,
9305            uncontested: Some("only candidate A produced a change".to_owned()),
9306        });
9307        state.reviews = vec![ReviewRound {
9308            round: 1,
9309            head: "deadbeef".to_owned(),
9310            verified_head: None,
9311            verified_at: None,
9312            reviews: Vec::new(),
9313            e2e: Vec::new(),
9314            fix: None,
9315            blocking: 0,
9316            answered: 0,
9317            expected: 0,
9318            clean: true,
9319            verify_retried: false,
9320            e2e_deferred: false,
9321            e2e_defer_reason: None,
9322            progressed: false,
9323            vote_split: false,
9324            reconsideration: Vec::new(),
9325            verdict: None,
9326        }];
9327
9328        let mut runner = Runner {
9329            state,
9330            roles: ResolvedRoles {
9331                implementers: Vec::new(),
9332                judges: Vec::new(),
9333                reviewers: Vec::new(),
9334                fixer: None,
9335                conductor: conductor(),
9336                implementer_roster: Vec::new(),
9337                judge_roster: Vec::new(),
9338                reviewer_roster: Vec::new(),
9339            },
9340            sem: Arc::new(Semaphore::new(1)),
9341            pause: Pause::new(),
9342            interrupt: Pause::new(),
9343        };
9344
9345        runner.gate().await.expect("gate");
9346        assert!(
9347            runner.state.gate_ran,
9348            "zero configured commands is still a real attempt, not an unrun gate"
9349        );
9350        assert!(runner.state.gate.is_empty());
9351        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
9352        assert_ne!(
9353            runner.state.status,
9354            RunStatus::Blocked,
9355            "a gate with nothing to check must not read as failed"
9356        );
9357
9358        runner.merge().await.expect("merge");
9359        assert_eq!(
9360            runner.state.status,
9361            RunStatus::Ready,
9362            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
9363        );
9364    }
9365
9366    /// `Config::cache_dir` is derived from `verify.e2e` as well as
9367    /// `verify.gate` (so the e2e leg and the final gate never build against
9368    /// different directories). With zero `verify.gate` commands but a
9369    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
9370    /// that lease before discovering it had nothing to run - so a repo with
9371    /// no gate commands could come back `resource_blocked` (and therefore
9372    /// still `gate_ran == false`) on nothing but an unrelated run holding the
9373    /// cache, exactly the contention this run's own zero commands could
9374    /// never have touched. `gate` must recognise there is nothing to check
9375    /// before it ever asks for the lease.
9376    #[tokio::test]
9377    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
9378        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9379        let home = crate::run::home();
9380
9381        let tmp = tempfile::tempdir().expect("tempdir");
9382        let repo = tmp.path().join("repo");
9383        std::fs::create_dir_all(&repo).unwrap();
9384        init_repo(&repo);
9385        // Unique to this test, so holding its lease cannot collide with
9386        // another test sharing the same process-wide `home`.
9387        let cache_dir = tmp.path().join("target");
9388
9389        let mut config = Config::default();
9390        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
9391        // `verify.gate` stays empty (the default). Bounded so a regression
9392        // that does start waiting fails the test in seconds, not hangs it.
9393        config.graph.timeout_verify = Some(2);
9394
9395        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9396        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9397            .expect("no io error acquiring directly")
9398        {
9399            crate::cache::AcquireOutcome::Acquired(g) => g,
9400            crate::cache::AcquireOutcome::Busy(b) => {
9401                panic!("expected the direct acquire to win the lease first: {b:?}")
9402            }
9403        };
9404
9405        let mut state = RunState::new(
9406            repo.clone(),
9407            "main".to_owned(),
9408            "deadbeef".to_owned(),
9409            "task".to_owned(),
9410            config,
9411        );
9412        state.candidates = vec![Candidate {
9413            index: 0,
9414            label: 'A',
9415            agent: "alpha".to_owned(),
9416            branch: "does-not-exist".to_owned(),
9417            worktree: repo.clone(),
9418            summary: String::new(),
9419            stat: String::new(),
9420            files: 0,
9421            commits: 0,
9422            empty: false,
9423            failed: None,
9424            verified_noop: None,
9425            duration_ms: 0,
9426            folded: false,
9427        }];
9428        state.tally = Some(Tally {
9429            first_choice: BTreeMap::from([('A', 1)]),
9430            borda: BTreeMap::new(),
9431            winner: 'A',
9432            rankings: 1,
9433            unanimous_initial: true,
9434            deliberated: false,
9435            changed_votes: 0,
9436            unanimous_final: true,
9437            tie_break: None,
9438            judges: 0,
9439            present: 0,
9440            quorum: 0,
9441            met_quorum: true,
9442            uncontested: Some("only candidate A produced a change".to_owned()),
9443        });
9444        state.reviews = vec![ReviewRound {
9445            round: 1,
9446            head: "deadbeef".to_owned(),
9447            verified_head: None,
9448            verified_at: None,
9449            reviews: Vec::new(),
9450            e2e: Vec::new(),
9451            fix: None,
9452            blocking: 0,
9453            answered: 0,
9454            expected: 0,
9455            clean: true,
9456            verify_retried: false,
9457            e2e_deferred: false,
9458            e2e_defer_reason: None,
9459            progressed: false,
9460            vote_split: false,
9461            reconsideration: Vec::new(),
9462            verdict: None,
9463        }];
9464
9465        let mut runner = Runner {
9466            state,
9467            roles: ResolvedRoles {
9468                implementers: Vec::new(),
9469                judges: Vec::new(),
9470                reviewers: Vec::new(),
9471                fixer: None,
9472                conductor: conductor(),
9473                implementer_roster: Vec::new(),
9474                judge_roster: Vec::new(),
9475                reviewer_roster: Vec::new(),
9476            },
9477            sem: Arc::new(Semaphore::new(1)),
9478            pause: Pause::new(),
9479            interrupt: Pause::new(),
9480        };
9481
9482        let started = std::time::Instant::now();
9483        runner.gate().await.expect("gate");
9484        assert!(
9485            started.elapsed() < Duration::from_secs(1),
9486            "a gate with nothing to run must never wait on a lease it never needed"
9487        );
9488        assert!(
9489            runner.state.gate_ran,
9490            "zero commands is still a real, immediate attempt"
9491        );
9492        assert!(runner.state.gate.is_empty());
9493        assert_ne!(
9494            runner.state.status,
9495            RunStatus::Blocked,
9496            "must not read as resource-blocked on a lease it never asked for"
9497        );
9498    }
9499
9500    /// The addendum's second gap: a `verify.gate` command running for real
9501    /// wall-clock time had nothing at all to show for it in `active` before
9502    /// `run_commands` learned to record it — a run could sit in `Gating` for
9503    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
9504    /// what was actually happening. Proven with a genuinely still-running
9505    /// command, not just a before/after check on the final state: a poller
9506    /// task reads the same `run.json` `gate()` is writing, the same way the
9507    /// phone or `magi show` would, while the shell command is still blocked
9508    /// on its own release marker.
9509    #[tokio::test]
9510    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
9511        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9512
9513        let tmp = tempfile::tempdir().expect("tempdir");
9514        let repo = tmp.path().join("repo");
9515        std::fs::create_dir_all(&repo).unwrap();
9516        init_repo(&repo);
9517
9518        let mut config = Config::default();
9519        config.verify.gate = vec![
9520            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
9521             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
9522                .to_owned(),
9523        ];
9524
9525        let mut state = RunState::new(
9526            repo.clone(),
9527            "main".to_owned(),
9528            "deadbeef".to_owned(),
9529            "task".to_owned(),
9530            config,
9531        );
9532        let run_id = state.id.clone();
9533        state.candidates = vec![Candidate {
9534            index: 0,
9535            label: 'A',
9536            agent: "alpha".to_owned(),
9537            branch: "does-not-exist".to_owned(),
9538            worktree: repo.clone(),
9539            summary: String::new(),
9540            stat: String::new(),
9541            files: 0,
9542            commits: 0,
9543            empty: false,
9544            failed: None,
9545            verified_noop: None,
9546            duration_ms: 0,
9547            folded: false,
9548        }];
9549        state.tally = Some(Tally {
9550            first_choice: BTreeMap::from([('A', 1)]),
9551            borda: BTreeMap::new(),
9552            winner: 'A',
9553            rankings: 1,
9554            unanimous_initial: true,
9555            deliberated: false,
9556            changed_votes: 0,
9557            unanimous_final: true,
9558            tie_break: None,
9559            judges: 0,
9560            present: 0,
9561            quorum: 0,
9562            met_quorum: true,
9563            uncontested: Some("only candidate A produced a change".to_owned()),
9564        });
9565        state.reviews = vec![ReviewRound {
9566            round: 1,
9567            head: "deadbeef".to_owned(),
9568            verified_head: None,
9569            verified_at: None,
9570            reviews: Vec::new(),
9571            e2e: Vec::new(),
9572            fix: None,
9573            blocking: 0,
9574            answered: 0,
9575            expected: 0,
9576            clean: true,
9577            verify_retried: false,
9578            e2e_deferred: false,
9579            e2e_defer_reason: None,
9580            progressed: false,
9581            vote_split: false,
9582            reconsideration: Vec::new(),
9583            verdict: None,
9584        }];
9585
9586        let mut runner = Runner {
9587            state,
9588            roles: ResolvedRoles {
9589                implementers: Vec::new(),
9590                judges: Vec::new(),
9591                reviewers: Vec::new(),
9592                fixer: None,
9593                conductor: conductor(),
9594                implementer_roster: Vec::new(),
9595                judge_roster: Vec::new(),
9596                reviewer_roster: Vec::new(),
9597            },
9598            sem: Arc::new(Semaphore::new(1)),
9599            pause: Pause::new(),
9600            interrupt: Pause::new(),
9601        };
9602
9603        let started_marker = repo.join("started.marker");
9604        let release_marker = repo.join("release.marker");
9605        let poller = tokio::spawn(async move {
9606            // Bounded so a regression that never records the task entry
9607            // fails this test in seconds instead of hanging the suite —
9608            // the same shape `a_park_requested_while_a_seat_is_mid_call_
9609            // does_not_cut_it_short` uses for the same reason.
9610            for _ in 0..100 {
9611                if started_marker.exists()
9612                    && let Ok(s) = crate::run::RunState::load(&run_id)
9613                    && let Some(a) = s.active.get("gate")
9614                {
9615                    std::fs::write(&release_marker, b"go").expect("release marker");
9616                    return Some(a.clone());
9617                }
9618                tokio::time::sleep(Duration::from_millis(50)).await;
9619            }
9620            None
9621        });
9622
9623        runner.gate().await.expect("gate");
9624        let captured = poller.await.expect("poller task");
9625        let captured = captured.expect(
9626            "the poller never saw a `gate` task entry in run.json while the command was \
9627             still blocked on its own release marker",
9628        );
9629
9630        assert_eq!(captured.task.as_deref(), Some("gate"));
9631        assert_eq!(captured.node, "gate");
9632        assert_eq!(captured.index, Some(1));
9633        assert_eq!(captured.total, Some(1));
9634        assert!(
9635            captured
9636                .command
9637                .as_deref()
9638                .is_some_and(|c| c.contains("started.marker")),
9639            "{captured:?}"
9640        );
9641
9642        assert!(
9643            runner.state.active.is_empty(),
9644            "the entry must be cleared once the command actually finished: {:?}",
9645            runner.state.active
9646        );
9647        assert!(runner.state.gate_ran);
9648        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
9649    }
9650
9651    /// The hand-off over a blocking finding a reviewer rejected on leaves a
9652    /// record for `land`; one with only a Minor, or no reject, leaves none.
9653    #[tokio::test]
9654    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
9655        use crate::verdict::{Finding, ReviewVote, Severity};
9656        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9657        let tmp = tempfile::tempdir().expect("tempdir");
9658        let repo = tmp.path().join("repo");
9659        std::fs::create_dir_all(&repo).unwrap();
9660        init_repo(&repo);
9661
9662        for (severity, vote, expect) in [
9663            (Severity::Major, ReviewVote::Reject, true),
9664            (Severity::Minor, ReviewVote::Reject, false),
9665            (Severity::Major, ReviewVote::Approve, false),
9666        ] {
9667            let mut round = review_round(false, 1, 1, 1, false, true);
9668            round.reviews = vec![ReviewRecord {
9669                reviewer: 1,
9670                agent: "alpha".to_owned(),
9671                summary: String::new(),
9672                findings: vec![Finding {
9673                    id: "R1-1-1".to_owned(),
9674                    severity,
9675                    file: None,
9676                    line: None,
9677                    title: "t".to_owned(),
9678                    detail: String::new(),
9679                }],
9680                vote: Some(vote),
9681                failed: None,
9682                duration_ms: 0,
9683                attempts: 0,
9684            }];
9685            let mut state = RunState::new(
9686                repo.clone(),
9687                "main".to_owned(),
9688                "deadbeef".to_owned(),
9689                "task".to_owned(),
9690                Config::default(),
9691            );
9692            state.reviews = vec![round];
9693            let mut runner = Runner {
9694                state,
9695                roles: ResolvedRoles {
9696                    implementers: Vec::new(),
9697                    judges: Vec::new(),
9698                    reviewers: Vec::new(),
9699                    fixer: None,
9700                    conductor: conductor(),
9701                    implementer_roster: Vec::new(),
9702                    judge_roster: Vec::new(),
9703                    reviewer_roster: Vec::new(),
9704                },
9705                sem: Arc::new(Semaphore::new(1)),
9706                pause: Pause::new(),
9707                interrupt: Pause::new(),
9708            };
9709            let shell = runner.state.config.shell();
9710            runner
9711                .stop_reviewing("round budget spent", &shell, &repo)
9712                .await
9713                .expect("stop_reviewing");
9714            assert_eq!(runner.state.status, RunStatus::Gating);
9715            assert_eq!(
9716                runner.state.contested_handoff.is_some(),
9717                expect,
9718                "{severity:?} + {vote:?}"
9719            );
9720        }
9721    }
9722
9723    /// The shape the incident this whole fix responds to actually had: the
9724    /// round budget spent, the last round's own e2e blocked on the shared
9725    /// build cache (held here by a live pid — this test process — exactly
9726    /// `cache`'s own unit tests' pattern for "another owner, still alive"
9727    /// without forking a process). `stop_reviewing` must retry it — not
9728    /// silently leave the round looking untouched (the catch-up-only half of
9729    /// the bug), and not read the contention as a red `e2e` and block the
9730    /// run on it (the other half). Called directly, the same way
9731    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
9732    /// above exercises `gate`, so this never needs a real cargo build to
9733    /// reach: the lease is never released, so `with_cache_lease` never gets
9734    /// past acquiring it into anything that would need a real workspace.
9735    #[tokio::test]
9736    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
9737        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9738        let home = crate::run::home();
9739
9740        let tmp = tempfile::tempdir().expect("tempdir");
9741        let repo = tmp.path().join("repo");
9742        std::fs::create_dir_all(&repo).unwrap();
9743        init_repo(&repo);
9744        let head = crate::git::rev_parse(&repo, "HEAD")
9745            .await
9746            .expect("rev-parse");
9747        // Unique to this test, so holding its lease cannot collide with
9748        // another test sharing the same process-wide `home`.
9749        let cache_dir = tmp.path().join("target");
9750
9751        let mut config = Config::default();
9752        config.verify.e2e = vec![format!(
9753            "CARGO_TARGET_DIR='{}' test -f README.md",
9754            cache_dir.display()
9755        )];
9756        config.graph.review_rounds = 1;
9757        // Bounded so a regression that does start waiting fails the test in
9758        // seconds, not hangs it.
9759        config.graph.timeout_verify = Some(2);
9760
9761        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9762        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9763            .expect("no io error acquiring directly")
9764        {
9765            crate::cache::AcquireOutcome::Acquired(g) => g,
9766            crate::cache::AcquireOutcome::Busy(b) => {
9767                panic!("expected the direct acquire to win the lease first: {b:?}")
9768            }
9769        };
9770
9771        let mut state = RunState::new(
9772            repo.clone(),
9773            "main".to_owned(),
9774            head.clone(),
9775            "task".to_owned(),
9776            config,
9777        );
9778        state.candidates = vec![Candidate {
9779            index: 0,
9780            label: 'A',
9781            agent: "alpha".to_owned(),
9782            branch: "does-not-exist".to_owned(),
9783            worktree: repo.clone(),
9784            summary: String::new(),
9785            stat: String::new(),
9786            files: 0,
9787            commits: 0,
9788            empty: false,
9789            failed: None,
9790            verified_noop: None,
9791            duration_ms: 0,
9792            folded: false,
9793        }];
9794        state.tally = Some(Tally {
9795            first_choice: BTreeMap::from([('A', 1)]),
9796            borda: BTreeMap::new(),
9797            winner: 'A',
9798            rankings: 1,
9799            unanimous_initial: true,
9800            deliberated: false,
9801            changed_votes: 0,
9802            unanimous_final: true,
9803            tie_break: None,
9804            judges: 0,
9805            present: 0,
9806            quorum: 0,
9807            met_quorum: true,
9808            uncontested: Some("only candidate A produced a change".to_owned()),
9809        });
9810        // The round budget's last round, deferred: `needs_catchup_run`'s
9811        // other trigger. `stop_reviewing`'s retry machinery must treat this
9812        // exactly like a resource-blocked attempt once it actually runs.
9813        state.reviews = vec![ReviewRound {
9814            round: 1,
9815            head: head.clone(),
9816            verified_head: None,
9817            verified_at: None,
9818            reviews: Vec::new(),
9819            e2e: Vec::new(),
9820            fix: None,
9821            blocking: 1,
9822            answered: 1,
9823            expected: 1,
9824            clean: false,
9825            verify_retried: false,
9826            e2e_deferred: true,
9827            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
9828            progressed: false,
9829            vote_split: false,
9830            reconsideration: Vec::new(),
9831            verdict: None,
9832        }];
9833
9834        let mut runner = Runner {
9835            state,
9836            roles: ResolvedRoles {
9837                implementers: Vec::new(),
9838                judges: Vec::new(),
9839                reviewers: Vec::new(),
9840                fixer: None,
9841                conductor: conductor(),
9842                implementer_roster: Vec::new(),
9843                judge_roster: Vec::new(),
9844                reviewer_roster: Vec::new(),
9845            },
9846            sem: Arc::new(Semaphore::new(1)),
9847            pause: Pause::new(),
9848            interrupt: Pause::new(),
9849        };
9850
9851        let shell = runner.state.config.shell();
9852        runner
9853            .stop_reviewing("round budget spent", &shell, &repo)
9854            .await
9855            .expect("stop_reviewing");
9856
9857        let last = runner.state.reviews.last().expect("round record");
9858        assert_eq!(
9859            last.e2e_status(),
9860            E2eStatus::ResourceBlocked,
9861            "the shared cache is still held; the attempt must read as blocked, not deferred or \
9862             failed: {last:?}"
9863        );
9864        assert_eq!(
9865            last.verified_head.as_deref(),
9866            Some(head.as_str()),
9867            "which commit this attempt targeted is known even though nothing finished checking \
9868             it"
9869        );
9870        let first_attempt_at = last
9871            .verified_at
9872            .expect("when this attempt ran is known too");
9873        assert_ne!(
9874            runner.state.status,
9875            RunStatus::Blocked,
9876            "contention is evidence about the machine, not the patch — it must not settle the \
9877             run as blocked: {:?}",
9878            runner.state.status
9879        );
9880        assert!(
9881            !runner
9882                .state
9883                .events
9884                .iter()
9885                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
9886            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
9887            runner.state.events
9888        );
9889
9890        // The cache is still held: a later reentry must retry the same
9891        // round's verification again — not leave it looking exactly as
9892        // untouched as the first blocked attempt, which is indistinguishable
9893        // from never having tried again at all.
9894        runner
9895            .stop_reviewing("round budget spent", &shell, &repo)
9896            .await
9897            .expect("stop_reviewing retry");
9898        assert_eq!(
9899            runner.state.reviews.len(),
9900            1,
9901            "no new round was started: {:?}",
9902            runner.state.reviews
9903        );
9904        let last = runner.state.reviews.last().expect("round record");
9905        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
9906        assert!(
9907            last.verified_at.expect("still known") > first_attempt_at,
9908            "a second reentry must be a fresh attempt, not a stale copy of the first"
9909        );
9910        assert_ne!(runner.state.status, RunStatus::Blocked);
9911
9912        held.release();
9913    }
9914
9915    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
9916    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
9917    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
9918    /// own top-of-function fast path (`review_conclusion`) correctly reads
9919    /// this shape as `None` rather than guessing `Blocked`, and the loop's
9920    /// own `for` range is empty once the round budget is spent, so
9921    /// `review_loop` must retry the check itself rather than silently doing
9922    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
9923    /// above exercises directly, but through `review_loop`'s own entry point
9924    /// this time, proving the wiring between the two rather than just the
9925    /// retry logic in isolation.
9926    #[tokio::test]
9927    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
9928        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
9929        let home = crate::run::home();
9930
9931        let tmp = tempfile::tempdir().expect("tempdir");
9932        let repo = tmp.path().join("repo");
9933        std::fs::create_dir_all(&repo).unwrap();
9934        init_repo(&repo);
9935        let head = crate::git::rev_parse(&repo, "HEAD")
9936            .await
9937            .expect("rev-parse");
9938        let cache_dir = tmp.path().join("target");
9939
9940        let mut config = Config::default();
9941        config.verify.e2e = vec![format!(
9942            "CARGO_TARGET_DIR='{}' test -f README.md",
9943            cache_dir.display()
9944        )];
9945        config.graph.review_rounds = 1;
9946        config.graph.timeout_verify = Some(2);
9947
9948        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9949        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9950            .expect("no io error acquiring directly")
9951        {
9952            crate::cache::AcquireOutcome::Acquired(g) => g,
9953            crate::cache::AcquireOutcome::Busy(b) => {
9954                panic!("expected the direct acquire to win the lease first: {b:?}")
9955            }
9956        };
9957
9958        let mut state = RunState::new(
9959            repo.clone(),
9960            "main".to_owned(),
9961            head.clone(),
9962            "task".to_owned(),
9963            config,
9964        );
9965        state.candidates = vec![Candidate {
9966            index: 0,
9967            label: 'A',
9968            agent: "alpha".to_owned(),
9969            branch: "does-not-exist".to_owned(),
9970            worktree: repo.clone(),
9971            summary: String::new(),
9972            stat: String::new(),
9973            files: 0,
9974            commits: 0,
9975            empty: false,
9976            failed: None,
9977            verified_noop: None,
9978            duration_ms: 0,
9979            folded: false,
9980        }];
9981        state.tally = Some(Tally {
9982            first_choice: BTreeMap::from([('A', 1)]),
9983            borda: BTreeMap::new(),
9984            winner: 'A',
9985            rankings: 1,
9986            unanimous_initial: true,
9987            deliberated: false,
9988            changed_votes: 0,
9989            unanimous_final: true,
9990            tie_break: None,
9991            judges: 0,
9992            present: 0,
9993            quorum: 0,
9994            met_quorum: true,
9995            uncontested: Some("only candidate A produced a change".to_owned()),
9996        });
9997        // The exact shape a prior process's `stop_reviewing` would have left
9998        // on disk: the round budget's last round, a real attempt already
9999        // made and already resource-blocked.
10000        state.reviews = vec![ReviewRound {
10001            round: 1,
10002            head: head.clone(),
10003            verified_head: Some(head.clone()),
10004            verified_at: Some(jiff::Timestamp::now()),
10005            reviews: Vec::new(),
10006            e2e: vec![CommandOutcome {
10007                command: format!(
10008                    "CARGO_TARGET_DIR='{}' test -f README.md",
10009                    cache_dir.display()
10010                ),
10011                code: None,
10012                output_tail: "waiting for the shared build cache".to_owned(),
10013                duration_ms: 0,
10014                resource_blocked: true,
10015            }],
10016            fix: None,
10017            blocking: 1,
10018            answered: 1,
10019            expected: 1,
10020            clean: false,
10021            verify_retried: false,
10022            e2e_deferred: false,
10023            e2e_defer_reason: None,
10024            progressed: false,
10025            vote_split: false,
10026            reconsideration: Vec::new(),
10027            verdict: None,
10028        }];
10029
10030        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10031        let mut runner = Runner {
10032            state,
10033            roles: ResolvedRoles {
10034                implementers: Vec::new(),
10035                judges: Vec::new(),
10036                reviewers: Vec::new(),
10037                fixer: None,
10038                conductor: conductor(),
10039                implementer_roster: Vec::new(),
10040                judge_roster: Vec::new(),
10041                reviewer_roster: Vec::new(),
10042            },
10043            sem: Arc::new(Semaphore::new(1)),
10044            pause: Pause::new(),
10045            interrupt: Pause::new(),
10046        };
10047
10048        // The lease is still held throughout, so this reentry's own retry is
10049        // also contended — proving `review_loop` actually tried again (not
10050        // that it happened to succeed) is what the timestamp comparison
10051        // below is for.
10052        runner.review_loop().await.expect("review_loop");
10053
10054        assert_eq!(
10055            runner.state.reviews.len(),
10056            1,
10057            "no new round was started on top of the unresolved one: {:?}",
10058            runner.state.reviews
10059        );
10060        let last = &runner.state.reviews[0];
10061        assert_eq!(
10062            last.e2e_status(),
10063            E2eStatus::ResourceBlocked,
10064            "still contended: {last:?}"
10065        );
10066        assert!(
10067            last.verified_at.expect("still known") > first_attempt_at,
10068            "review_loop must have actually retried the check, not left it exactly as found"
10069        );
10070        assert_ne!(
10071            runner.state.status,
10072            RunStatus::Blocked,
10073            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10074            runner.state.status
10075        );
10076
10077        held.release();
10078    }
10079
10080    #[tokio::test]
10081    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10082        crate::run::set_home(std::env::temp_dir().join("magi-graph-test-home"));
10083        let tmp = tempfile::tempdir().expect("tempdir");
10084        let repo = tmp.path().join("repo");
10085        std::fs::create_dir_all(&repo).unwrap();
10086        init_repo(&repo);
10087
10088        let mut config = Config::default();
10089        config.merge.mode = MergeMode::Pr;
10090        config.graph.land = true;
10091        config.graph.land_approval = false;
10092
10093        let mut state = RunState::new(
10094            repo.clone(),
10095            "main".to_owned(),
10096            "deadbeef".to_owned(),
10097            "task".to_owned(),
10098            config,
10099        );
10100        state.candidates = vec![Candidate {
10101            index: 0,
10102            label: 'A',
10103            agent: "alpha".to_owned(),
10104            branch: "does-not-exist".to_owned(),
10105            worktree: repo.clone(),
10106            summary: String::new(),
10107            stat: String::new(),
10108            files: 0,
10109            commits: 0,
10110            empty: false,
10111            failed: None,
10112            verified_noop: None,
10113            duration_ms: 0,
10114            folded: false,
10115        }];
10116        state.tally = Some(Tally {
10117            first_choice: BTreeMap::from([('A', 1)]),
10118            borda: BTreeMap::new(),
10119            winner: 'A',
10120            rankings: 1,
10121            unanimous_initial: true,
10122            deliberated: false,
10123            changed_votes: 0,
10124            unanimous_final: true,
10125            tie_break: None,
10126            judges: 0,
10127            present: 0,
10128            quorum: 0,
10129            met_quorum: true,
10130            uncontested: Some("only candidate A produced a change".to_owned()),
10131        });
10132        state.reviews = vec![ReviewRound {
10133            round: 1,
10134            head: "deadbeef".to_owned(),
10135            verified_head: None,
10136            verified_at: None,
10137            reviews: Vec::new(),
10138            e2e: Vec::new(),
10139            fix: None,
10140            blocking: 0,
10141            answered: 0,
10142            expected: 0,
10143            clean: true,
10144            verify_retried: false,
10145            e2e_deferred: false,
10146            e2e_defer_reason: None,
10147            progressed: false,
10148            vote_split: false,
10149            reconsideration: Vec::new(),
10150            verdict: None,
10151        }];
10152        state.gate = vec![CommandOutcome {
10153            command: "test".to_owned(),
10154            code: Some(0),
10155            output_tail: String::new(),
10156            duration_ms: 0,
10157            resource_blocked: false,
10158        }];
10159        state.gate_ran = true;
10160        // A first pass through `merge` already pushed and opened this pull
10161        // request; `status` is `Landing` because a previous call into `land`
10162        // parked or was interrupted before it reached a terminal outcome.
10163        state.status = RunStatus::Landing;
10164        state.merge = Some(MergeOutcome {
10165            mode: MergeMode::Pr,
10166            ok: true,
10167            detail: "https://example.invalid/x/y/pull/1".to_owned(),
10168            empty: false,
10169        });
10170
10171        // The Landing-resume shortcut calls `run_land` directly rather than
10172        // through `merge`, which is exactly the call site that used to skip
10173        // `settle_questions` - see the fixture below.
10174        ask_test_home();
10175        let store = ask::Questions::open();
10176        let q = ask_open_question(&store, &state.id);
10177
10178        let mut runner = Runner {
10179            state,
10180            roles: ResolvedRoles {
10181                implementers: Vec::new(),
10182                judges: Vec::new(),
10183                reviewers: Vec::new(),
10184                fixer: None,
10185                conductor: conductor(),
10186                implementer_roster: Vec::new(),
10187                judge_roster: Vec::new(),
10188                reviewer_roster: Vec::new(),
10189            },
10190            sem: Arc::new(Semaphore::new(1)),
10191            pause: Pause::new(),
10192            interrupt: Pause::new(),
10193        };
10194
10195        // `execute`, not `merge` directly: the Landing-resume shortcut lives
10196        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
10197        // exactly because `review_loop` would otherwise clobber the marker
10198        // first.
10199        runner.execute().await.expect("execute");
10200
10201        assert_eq!(
10202            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10203            Some("https://example.invalid/x/y/pull/1"),
10204            "reentry must not push again or open a second pull request over the \
10205             one `land` is already watching"
10206        );
10207        assert_ne!(
10208            runner.state.status,
10209            RunStatus::Landing,
10210            "land could not actually reach the fake pull request, so it must \
10211             have given up rather than left the run silently parked forever"
10212        );
10213        // `land` could not reach the fake pull request, so it gave up into
10214        // `Blocked` - still resumable, so the question must not have been
10215        // swept just because this branch now also calls `settle_questions`.
10216        assert_eq!(runner.state.status, RunStatus::Blocked);
10217        assert!(
10218            store.get(&q.id).unwrap().status.open(),
10219            "Blocked is still alive; settle_questions must have been a no-op here"
10220        );
10221    }
10222
10223    fn state_with_round(round: ReviewRound) -> RunState {
10224        let mut s = RunState::new(
10225            PathBuf::from("/repo"),
10226            "main".to_owned(),
10227            "abc1234".to_owned(),
10228            "add retries".to_owned(),
10229            Config::default(),
10230        );
10231        s.reviews = vec![round];
10232        s
10233    }
10234
10235    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10236        crate::verdict::Finding {
10237            id: id.to_owned(),
10238            severity,
10239            file: None,
10240            line: None,
10241            title: title.to_owned(),
10242            detail: String::new(),
10243        }
10244    }
10245
10246    #[test]
10247    fn pr_body_names_open_findings_and_declined_ones() {
10248        let round = ReviewRound {
10249            round: 2,
10250            head: "deadbee".to_owned(),
10251            verified_head: None,
10252            verified_at: None,
10253            reviews: vec![ReviewRecord {
10254                attempts: 0,
10255                reviewer: 1,
10256                agent: "alpha".to_owned(),
10257                summary: String::new(),
10258                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10259                vote: None,
10260                failed: None,
10261                duration_ms: 0,
10262            }],
10263            e2e: vec![CommandOutcome {
10264                command: "cargo test".to_owned(),
10265                code: Some(0),
10266                output_tail: String::new(),
10267                duration_ms: 0,
10268                resource_blocked: false,
10269            }],
10270            verify_retried: false,
10271            e2e_deferred: false,
10272            e2e_defer_reason: None,
10273            fix: Some(FixRecord {
10274                agent: "alpha".to_owned(),
10275                addressed: Vec::new(),
10276                rejected: vec![crate::verdict::Rejection {
10277                    id: "R1-1-1".to_owned(),
10278                    why: "not reachable from any caller".to_owned(),
10279                }],
10280                notes: String::new(),
10281                committed: true,
10282                failed: None,
10283                duration_ms: 0,
10284                continuation: None,
10285            }),
10286            blocking: 0,
10287            answered: 1,
10288            expected: 1,
10289            clean: false,
10290            progressed: true,
10291            vote_split: false,
10292            reconsideration: Vec::new(),
10293            verdict: None,
10294        };
10295        let state = state_with_round(round);
10296        let body = pr_message(&state, 'A').body;
10297
10298        assert!(body.contains("add retries"), "the task must still be there");
10299        assert!(body.contains("R2-1-1"), "{body}");
10300        assert!(body.contains("unused import"), "{body}");
10301        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
10302        assert!(
10303            body.contains("not reachable from any caller"),
10304            "the reason it was declined: {body}"
10305        );
10306    }
10307
10308    #[test]
10309    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
10310        let round = ReviewRound {
10311            round: 1,
10312            head: "deadbee".to_owned(),
10313            verified_head: None,
10314            verified_at: None,
10315            reviews: vec![ReviewRecord {
10316                attempts: 0,
10317                reviewer: 1,
10318                agent: "alpha".to_owned(),
10319                summary: String::new(),
10320                findings: Vec::new(),
10321                vote: None,
10322                failed: None,
10323                duration_ms: 0,
10324            }],
10325            e2e: Vec::new(),
10326            verify_retried: false,
10327            e2e_deferred: false,
10328            e2e_defer_reason: None,
10329            fix: None,
10330            blocking: 0,
10331            answered: 1,
10332            expected: 1,
10333            clean: true,
10334            progressed: false,
10335            vote_split: false,
10336            reconsideration: Vec::new(),
10337            verdict: None,
10338        };
10339        let state = state_with_round(round);
10340        let body = pr_message(&state, 'A').body;
10341        assert!(!body.contains("Open review findings"), "{body}");
10342        assert!(!body.contains("Declined"), "{body}");
10343    }
10344
10345    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
10346        let mut state = RunState::new(
10347            PathBuf::from("/repo"),
10348            "main".to_owned(),
10349            "abc1234".to_owned(),
10350            instruction.to_owned(),
10351            Config::default(),
10352        );
10353        state.candidates.push(Candidate {
10354            index: 0,
10355            label: 'A',
10356            agent: "alpha".to_owned(),
10357            branch: "magi/x/A".to_owned(),
10358            worktree: PathBuf::from("/wt"),
10359            summary: summary.to_owned(),
10360            stat: String::new(),
10361            files: 1,
10362            commits: 1,
10363            empty: false,
10364            failed: None,
10365            verified_noop: None,
10366            folded: false,
10367            duration_ms: 0,
10368        });
10369        state
10370    }
10371
10372    fn review_state(subjects: &[&str]) -> RunState {
10373        let mut state = state_with_summary(
10374            "Review the work already on branch `magi/x/A`. There is no task statement: what the change claims to do is whatever its commits say.\n\nfirst\nsecond",
10375            "",
10376        );
10377        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
10378        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
10379        state
10380    }
10381
10382    #[test]
10383    fn pr_message_review_single_commit_uses_its_subject() {
10384        let state = review_state(&["feat(nats): per-role user"]);
10385        let m = pr_message(&state, 'A');
10386        assert_eq!(m.title, "feat(nats): per-role user");
10387        assert!(!m.body.contains("Review the work already"), "{}", m.body);
10388        assert!(m.body.contains("## Commits under review"), "{}", m.body);
10389    }
10390
10391    #[test]
10392    fn pr_message_review_multi_commit_takes_the_oldest() {
10393        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
10394        let m = pr_message(&state, 'A');
10395        assert_eq!(m.title, "feat: the change");
10396        for s in ["feat: the change", "fix: typo", "fix: again"] {
10397            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
10398        }
10399    }
10400
10401    #[test]
10402    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
10403        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
10404            let mut state = review_state(&[first, "fix: later fixup"]);
10405            state.candidates[0].branch = "機能/ログイン".to_owned();
10406            let m = pr_message(&state, 'A');
10407            assert!(
10408                m.title.starts_with("chore: land candidate A of run"),
10409                "{}",
10410                m.title
10411            );
10412        }
10413    }
10414
10415    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
10416        BranchFacts {
10417            commits: commits
10418                .iter()
10419                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
10420                .collect(),
10421            stat: stat.to_owned(),
10422        }
10423    }
10424
10425    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
10426        let mut r = review_round(true, 0, 1, 1, true, true);
10427        r.round = round;
10428        r.fix = notes.map(|n| FixRecord {
10429            agent: "fixer".to_owned(),
10430            addressed: Vec::new(),
10431            rejected: Vec::new(),
10432            notes: n.to_owned(),
10433            committed: true,
10434            failed: None,
10435            duration_ms: 0,
10436            continuation: None,
10437        });
10438        r
10439    }
10440
10441    #[test]
10442    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
10443        let state = review_state(&["ignored"]);
10444        let f = facts(
10445            &[
10446                (
10447                    "fix(login): resolve PATH on macOS",
10448                    "Login shells skip rc files.",
10449                ),
10450                ("fix: address review", ""),
10451            ],
10452            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
10453        );
10454        let m = pr_message_with(&state, 'A', Some(&f));
10455        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
10456        assert!(
10457            m.body
10458                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
10459            "{}",
10460            m.body
10461        );
10462        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
10463        assert!(m.body.contains("## Diff stat"), "{}", m.body);
10464        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
10465        for banned in [
10466            "Review the work already",
10467            "no task statement",
10468            "Original task",
10469        ] {
10470            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
10471        }
10472        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
10473    }
10474
10475    #[test]
10476    fn pr_message_review_truncates_a_huge_first_commit_body() {
10477        let state = review_state(&["ignored"]);
10478        let f = facts(
10479            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
10480            "s",
10481        );
10482        let m = pr_message_with(&state, 'A', Some(&f));
10483        assert!(m.body.len() < 30_000, "{}", m.body.len());
10484        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
10485        assert!(
10486            m.body.contains("- ... 1 more commit(s)"),
10487            "{}",
10488            m.body.len()
10489        );
10490        assert!(m.body.ends_with("magi:candidate-a\n"));
10491    }
10492
10493    #[test]
10494    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
10495        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
10496        assert_eq!(m.title, "feat: x");
10497        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
10498        assert!(!m.body.contains("Diff stat"), "{}", m.body);
10499        assert!(!m.body.contains("no task statement"), "{}", m.body);
10500    }
10501
10502    #[test]
10503    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
10504        let mut state = review_state(&["日本語の件名"]);
10505        state.candidates[0].branch = "fix/macos-login-path".to_owned();
10506        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
10507        state.candidates[0].branch = "機能/ログイン".to_owned();
10508        assert!(
10509            pr_message(&state, 'A')
10510                .title
10511                .starts_with("chore: land candidate A")
10512        );
10513    }
10514
10515    #[test]
10516    fn pr_message_review_fixes_survive_a_clean_final_round() {
10517        let mut state = review_state(&["feat: x"]);
10518        state.reviews = vec![
10519            round_with_notes(1, Some("handled the PATH case")),
10520            round_with_notes(2, None),
10521        ];
10522        let body = pr_message(&state, 'A').body;
10523        assert!(
10524            body.contains("## Review fixes\n\nhandled the PATH case\n"),
10525            "{body}"
10526        );
10527        assert!(!body.contains("### Round"), "{body}");
10528
10529        state.reviews = vec![
10530            round_with_notes(1, Some("first fix")),
10531            round_with_notes(2, Some("")),
10532            round_with_notes(3, Some("second fix")),
10533            round_with_notes(4, None),
10534        ];
10535        let body = pr_message(&state, 'A').body;
10536        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
10537        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
10538        assert!(!body.contains("### Round 2"), "{body}");
10539    }
10540
10541    #[test]
10542    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
10543        let state = state_with_summary(
10544            "add retries to the client",
10545            "TITLE: feat: retries\n\nDid it.",
10546        );
10547        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
10548        assert_eq!(m.title, "feat: retries");
10549        assert!(
10550            m.body.contains("<summary>Original task</summary>"),
10551            "{}",
10552            m.body
10553        );
10554        assert!(!m.body.contains("Commits under review"), "{}", m.body);
10555        assert!(
10556            m.body
10557                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
10558            "{}",
10559            m.body
10560        );
10561    }
10562
10563    #[test]
10564    fn pr_message_review_bounds_a_long_english_subject() {
10565        let long = format!("feat: {}", "word ".repeat(100));
10566        let m = pr_message(&review_state(&[&long]), 'A');
10567        assert!(m.title.starts_with("feat: word"), "{}", m.title);
10568        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
10569    }
10570
10571    #[test]
10572    fn pr_message_implementation_run_is_unchanged_by_review_support() {
10573        let state = state_with_summary("add retries\n\ndetails", "- did some things");
10574        let m = pr_message(&state, 'A');
10575        assert_eq!(m.title, "add retries");
10576        assert!(m.body.contains("<summary>Original task</summary>"));
10577        assert!(!m.body.contains("Commits under review"));
10578        assert_eq!(landing_subject_source(&state), state.instruction);
10579    }
10580
10581    #[test]
10582    fn review_run_squash_subject_is_the_change_not_the_prompt() {
10583        let state = review_state(&["feat: the change", "fix: typo"]);
10584        let source = landing_subject_source(&state);
10585        assert_eq!(land::merge_subject("", &source), "feat: the change");
10586        assert_eq!(
10587            land::merge_subject("magi: candidate A (uncommitted work)", &source),
10588            "feat: the change"
10589        );
10590        // An operator's rename still wins.
10591        assert_eq!(
10592            land::merge_subject("feat: renamed by hand", &source),
10593            "feat: renamed by hand"
10594        );
10595        let blank = review_state(&["日本語"]);
10596        assert!(
10597            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
10598        );
10599    }
10600
10601    #[test]
10602    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
10603        let state = review_state(&["feat: the change"]);
10604        let source = landing_subject_source(&state);
10605        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
10606        assert_eq!(
10607            land::merge_subject(landing_title(&state, old), &source),
10608            "feat: the change"
10609        );
10610        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
10611        let task = state_with_summary("add retries", "");
10612        assert_eq!(landing_title(&task, old), old);
10613    }
10614
10615    #[test]
10616    fn pr_message_describes_the_change_not_the_task() {
10617        let state = state_with_summary(
10618            "今回やってほしいこと: results projector を直す",
10619            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
10620        );
10621        let m = pr_message(&state, 'A');
10622        assert_eq!(m.title, "fix(web): batch the runs list reads");
10623        assert!(
10624            m.body.starts_with("## Summary\n\n- reads run.json once"),
10625            "{}",
10626            m.body
10627        );
10628        assert!(!m.body.contains("TITLE:"), "{}", m.body);
10629        let task_at = m.body.find("今回やってほしいこと").unwrap();
10630        let details_at = m.body.find("<details>").unwrap();
10631        assert!(
10632            details_at < task_at,
10633            "the task lives inside <details>: {}",
10634            m.body
10635        );
10636        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
10637        assert!(m.body.contains("magi:candidate-a"));
10638    }
10639
10640    #[test]
10641    fn pr_message_falls_back_to_the_task_without_a_title_line() {
10642        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
10643        let m = pr_message(&state, 'A');
10644        assert_eq!(m.title, "add retries");
10645        assert!(
10646            m.body.contains("## Summary\n\n- did some things"),
10647            "{}",
10648            m.body
10649        );
10650
10651        let none = RunState::new(
10652            PathBuf::from("/repo"),
10653            "main".to_owned(),
10654            "abc1234".to_owned(),
10655            "add retries".to_owned(),
10656            Config::default(),
10657        );
10658        let m = pr_message(&none, 'A');
10659        assert_eq!(m.title, "add retries");
10660        assert!(!m.body.contains("## Summary"), "{}", m.body);
10661    }
10662
10663    #[test]
10664    fn pr_message_refuses_the_candidate_commit_subject() {
10665        for bad in [
10666            "TITLE: magi: candidate A (uncommitted work)",
10667            "TITLE: chore: stuff (uncommitted work)",
10668            "TITLE:   ",
10669        ] {
10670            let state = state_with_summary("add retries", bad);
10671            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
10672        }
10673    }
10674
10675    #[test]
10676    fn pr_message_bounds_a_very_long_task_and_title() {
10677        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
10678        let state = state_with_summary(&long, "- nothing");
10679        let m = pr_message(&state, 'A');
10680        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
10681        assert!(!m.title.contains('\n'));
10682
10683        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
10684        let m = pr_message(&state, 'A');
10685        assert!(m.title.starts_with("feat: "));
10686        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
10687        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
10688    }
10689
10690    #[test]
10691    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
10692        // What magi itself writes stays English under any configured language,
10693        // so a future localisation of these headings fails here. (The agents'
10694        // own text is held to English by the prompt only; magi cannot check it.)
10695        let mut state = state_with_summary(
10696            "add retries",
10697            "TITLE: fix(web): batch reads\n- reads run.json once",
10698        );
10699        state.config.graph.language = "ja".to_owned();
10700        let m = pr_message(&state, 'A');
10701        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
10702
10703        // The task is the operator's own text: it goes in untouched, and the
10704        // fallback title (no summary) may be in its language too.
10705        let task = "今回やってほしいこと: results projector を直す";
10706        let mut state = state_with_summary(task, "- no title line");
10707        state.config.graph.language = "ja".to_owned();
10708        let m = pr_message(&state, 'A');
10709        assert_eq!(
10710            m.title,
10711            format!("chore: land candidate A of run {}", state.id)
10712        );
10713        assert!(
10714            m.body.contains(&format!(
10715                "<summary>Original task</summary>\n\n{task}\n\n</details>"
10716            )),
10717            "{}",
10718            m.body
10719        );
10720    }
10721
10722    #[test]
10723    fn pr_message_scrubs_home_paths_and_addresses() {
10724        let state = state_with_summary(
10725            "fix it in /Users/someone/src/x",
10726            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
10727        );
10728        let m = pr_message(&state, 'A');
10729        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
10730            assert!(!m.body.contains(leak), "{}", m.body);
10731        }
10732        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
10733    }
10734
10735    #[test]
10736    fn pr_message_survives_a_task_that_closes_details() {
10737        let state = state_with_summary("a </details> b", "TITLE: fix: x");
10738        let m = pr_message(&state, 'A');
10739        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
10740    }
10741
10742    #[test]
10743    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
10744        let cmd = manual_merge_command(
10745            MergeStyle::Squash,
10746            Path::new("/repo"),
10747            "b",
10748            "fix: \"quoted\" $(x) `y`\n\nbody",
10749        );
10750        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
10751    }
10752
10753    #[test]
10754    fn manual_merge_command_matches_the_configured_style() {
10755        let repo = Path::new("/repo");
10756        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
10757
10758        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
10759        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
10760
10761        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
10762        assert_eq!(
10763            squash,
10764            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
10765             \"Merge magi run 0832 (candidate A)\""
10766        );
10767
10768        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
10769        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
10770    }
10771
10772    #[test]
10773    fn a_nudge_gets_a_quarter_of_the_budget() {
10774        // The judge and implement budgets magi ships with.
10775        assert_eq!(retry_budget(secs(1200), true), secs(300));
10776        assert_eq!(retry_budget(secs(3600), true), secs(900));
10777    }
10778
10779    #[test]
10780    fn a_resent_prompt_keeps_the_whole_budget() {
10781        // The seat kept no context, so the retry is the original job again and
10782        // shortening it would only guarantee a second failure.
10783        assert_eq!(retry_budget(secs(1200), false), secs(1200));
10784        assert_eq!(retry_budget(secs(60), false), secs(60));
10785    }
10786
10787    #[test]
10788    fn the_floor_never_exceeds_the_original_budget() {
10789        // A short configured timeout must not be *raised* by the floor: the
10790        // operator asked for a bound, and a retry may not outlast the attempt
10791        // it is retrying.
10792        assert_eq!(retry_budget(secs(60), true), secs(60));
10793        assert_eq!(retry_budget(secs(480), true), secs(120));
10794        assert_eq!(retry_budget(secs(0), true), secs(0));
10795    }
10796
10797    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
10798        agent::CommandEvidence {
10799            id: "item1".to_owned(),
10800            description: "cargo test".to_owned(),
10801            exit_code,
10802            result_summary: String::new(),
10803            source: "codex".to_owned(),
10804        }
10805    }
10806
10807    #[test]
10808    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
10809        // No evidence is not the same fact as unconfirmed evidence: a
10810        // backend with no adapter, or a reply that ran no commands at all,
10811        // must not be misread as carrying a dangling job.
10812        assert!(!has_unconfirmed_command(&[]));
10813    }
10814
10815    #[test]
10816    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
10817        // Deliberately not a check on the exit code's *value*: a fixer
10818        // legitimately runs something that fails mid-iteration before it
10819        // succeeds, and that must never by itself reopen a valid report.
10820        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
10821        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
10822        assert!(!has_unconfirmed_command(&[
10823            evidence(Some(0)),
10824            evidence(Some(101))
10825        ]));
10826    }
10827
10828    #[test]
10829    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
10830        assert!(has_unconfirmed_command(&[
10831            evidence(Some(0)),
10832            evidence(None)
10833        ]));
10834    }
10835
10836    #[test]
10837    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
10838        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
10839        assert_eq!(
10840            verified_noop_claim(true, &[], text).as_deref(),
10841            Some("already fixed by b32cfc4, on main.")
10842        );
10843    }
10844
10845    #[test]
10846    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
10847        // A timeout or a bad exit code reads as the ordinary loss it is,
10848        // whatever the reply's own prose claims.
10849        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
10850        assert!(verified_noop_claim(false, &[], text).is_none());
10851    }
10852
10853    #[test]
10854    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
10855        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
10856        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
10857        // A confirmed command alongside the marker is fine.
10858        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
10859    }
10860
10861    #[test]
10862    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
10863        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
10864    }
10865
10866    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
10867    /// pair, in order, labelled A, B, C, ...
10868    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
10869        runner.state.candidates = shape
10870            .iter()
10871            .enumerate()
10872            .map(|(i, &(empty, verified))| Candidate {
10873                index: i,
10874                label: (b'A' + i as u8) as char,
10875                agent: "sonnet".to_owned(),
10876                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
10877                worktree: PathBuf::from(format!("/wt/{i}")),
10878                summary: String::new(),
10879                stat: String::new(),
10880                files: 0,
10881                commits: 0,
10882                empty,
10883                failed: None,
10884                verified_noop: verified.map(str::to_owned),
10885                duration_ms: 0,
10886                folded: false,
10887            })
10888            .collect();
10889    }
10890
10891    #[test]
10892    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
10893        ask_test_home();
10894        let mut runner = runner_at(RunStatus::Implementing);
10895        set_candidates(
10896            &mut runner,
10897            &[
10898                (true, Some("already on main at b32cfc4")),
10899                (true, Some("same fix, see the existing test")),
10900            ],
10901        );
10902
10903        runner
10904            .after_implement()
10905            .expect("a verified no-op is not an error");
10906
10907        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
10908    }
10909
10910    #[test]
10911    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
10912        ask_test_home();
10913        let mut runner = runner_at(RunStatus::Implementing);
10914        // Candidate A declares a verified no-op; candidate B simply wrote
10915        // nothing and said nothing about why. One candidate's claim is not
10916        // the whole run's agreement.
10917        set_candidates(
10918            &mut runner,
10919            &[(true, Some("already on main at b32cfc4")), (true, None)],
10920        );
10921
10922        let err = runner
10923            .after_implement()
10924            .expect_err("an unverified empty candidate must still fail the run");
10925
10926        assert!(
10927            err.to_string().contains("no candidate produced a change"),
10928            "{err}"
10929        );
10930        assert_eq!(runner.state.status, RunStatus::Failed);
10931    }
10932
10933    #[test]
10934    fn after_implement_still_fails_an_ordinary_all_empty_run() {
10935        ask_test_home();
10936        let mut runner = runner_at(RunStatus::Implementing);
10937        set_candidates(&mut runner, &[(true, None), (true, None)]);
10938
10939        let err = runner
10940            .after_implement()
10941            .expect_err("no candidate declared anything; this is an ordinary failure");
10942
10943        assert!(
10944            err.to_string().contains("no candidate produced a change"),
10945            "{err}"
10946        );
10947        assert_eq!(runner.state.status, RunStatus::Failed);
10948    }
10949}