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::fixer;
39use crate::git;
40use crate::graft;
41use crate::land;
42use crate::proc::Quiet as _;
43use crate::prompt::{
44    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
45};
46use crate::queue;
47use crate::refs;
48use crate::run::{
49    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
50    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord,
51    GithubTextChoice, GraftOutcome, GraftRecord, Handover, JobRecord, JobStatus, Judgement,
52    MergeOutcome, OperatorFixFinding, OperatorFixOutcome, OperatorFixRequest, Origin, QuotaLoss,
53    ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState, RunStatus, SeatHistory, Tally,
54    VoteRecord, tail, write_artifact,
55};
56use crate::verdict::{
57    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
58    ReviewVote, Severity,
59};
60use crate::worktree_setup;
61
62/// How much verification output is kept and fed back to the fixer.
63const OUTPUT_TAIL: usize = 8_000;
64
65/// Bytes of a failing command's output kept in an event, so the reason a run
66/// stopped is readable from the report without opening `run.json`.
67const EVENT_OUTPUT_TAIL: usize = 2_000;
68
69/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
70/// command's pid before releasing the build cache's lease.
71const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
72
73/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
74/// command's pid to actually exit before giving up and releasing anyway.
75///
76/// A timeout means the process was asked to die (`kill_on_drop`,
77/// `start_kill`), not that it already has — on Windows in particular that can
78/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
79/// the instant the command returns would let the very next acquirer (this
80/// run's own next round, another run's verification, the janitor's prune)
81/// start touching the same directory while it might still be writing to it,
82/// so this polls the actual pid — real confirmation, not a fixed guess —
83/// until it is gone or this ceiling is reached. It is still not full
84/// process-tree reaping: a grandchild the timed-out process spawned and that
85/// outlives it independently is invisible to a pid check, and continuing to
86/// observe and collect *that* stays a different piece of work with its own
87/// owner. Set generously because the common case returns early the moment
88/// the pid is confirmed gone, not because every timeout pays this in full.
89const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
90
91/// Consecutive review rounds with no tree progress (see
92/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
93/// instead of spending the rest of the round budget.
94///
95/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
96/// legitimately finds nothing left to change (its previous round's fix already
97/// covered it, and this round's reviewers re-raised only nits) looks the same
98/// as one that is spinning, for exactly one round. Two in a row is where the
99/// two stop being distinguishable, and a review round on this workload has
100/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
101/// third attempt at a tree that has not moved twice running is pure cost.
102/// This does not touch `review_rounds` itself, which stays the operator's
103/// call.
104pub(crate) const STAGNANT_LIMIT: usize = 2;
105
106/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
107/// a base that moved before giving up and leaving the run `Blocked` for a
108/// person.
109///
110/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
111/// that keeps moving faster than a run can catch it is not something more
112/// rebasing fixes, it is a person's call. Not the same *number as*
113/// `land_rounds` - this budget is spent before a pull request exists, land's
114/// after - but bounded for the identical reason, so it uses the same
115/// default. Counted across both call sites in [`Runner::finish_after_tally`]
116/// (once before review, once before the gate), because either one finding
117/// the base still moving is the same signal.
118const BASE_SYNC_ROUNDS: usize = 4;
119
120/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
121/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
122/// reply held no [`FixReport`].
123///
124/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
125/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
126/// "I'll pause here until the `cargo make check` background run reports
127/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
128/// No `FixReport` was ever collected from that seat, and the run moved on to
129/// the next review round regardless.
130///
131/// Bounded independently of `review_rounds` and `graph.retries`: this
132/// recovers one seat's missing report mid-round, not a new round of review or
133/// an ordinary parse retry, and must not itself become the unbounded wait the
134/// rest of this module exists to avoid.
135const MAX_FIX_CONTINUATIONS: usize = 2;
136
137/// One queued agent invocation.
138///
139/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
140/// CLI hung up on its own stream is asked again from the same job rather than
141/// rebuilt from scratch. See [`Runner::resume_undelivered`].
142#[derive(Clone)]
143struct SeatJob {
144    spec: AgentSpec,
145    seat: SeatState,
146    cwd: PathBuf,
147    prompt: String,
148    timeout: Duration,
149    allow_write: bool,
150    sessions: bool,
151    artifacts: PathBuf,
152    stem: String,
153    /// The prompt for a seat that has been handed to another roster agent
154    /// (a fresh session): everything the original seat would have
155    /// remembered. `None` when `prompt` already carries it, as the first
156    /// ranking and the implement prompt do. Never a resume-style prompt.
157    handover: Option<String>,
158}
159
160/// How the graph reads one agent invocation.
161///
162/// Quota is split out from an ordinary failure on purpose: a rate-limited call
163/// is known to fail again if retried now, so the retry loop must not spend an
164/// attempt on it. `Dropped` is split out for the opposite reason: unlike
165/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
166/// error JSON, never the agent's answer — a caller that matched only
167/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
168/// left to read that JSON as if it were usable output. `resume_undelivered`
169/// is the only caller that acts on it; everywhere else it is reported like an
170/// ordinary failure.
171enum AgentOutcome {
172    /// A usable output.
173    Ok(AgentOutput),
174    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
175    Quota(AgentOutput),
176    /// The CLI hung up on its own stream after billed work. See
177    /// [`agent::AgentOutput::work_undelivered`].
178    Dropped(AgentOutput),
179    /// Any other failure: a timeout, a bad exit code, an empty reply.
180    Failed(String),
181}
182
183/// A request to park the run at its next node boundary.
184///
185/// Cloning is how the request travels: the loop keeps one handle and hands a
186/// clone to each [`Runner`], and every clone points at the same flag. There
187/// is no channel because there is nothing to send - the only message is
188/// "park", it is idempotent, and a flag cannot be missed by a receiver that
189/// was not listening yet.
190///
191/// The boundary is what makes this cheap. Every node writes the run's state
192/// before the next one starts, and every node skips what is already recorded:
193/// `prep` returns early once candidates exist, `implement` asks only the seats
194/// with nothing on disk, `judge` returns early once judgements exist. So a
195/// parked run resumes into exactly the node it stopped before, and no agent
196/// work is thrown away. Killing the process mid-node, by contrast, loses
197/// whatever the seats in flight had not yet written - which for an implement
198/// wave is an hour of paid work.
199///
200/// A [`Runner`] watches two independent handles of this type - see
201/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
202/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
203/// clone covering the whole daemon's lifetime and is never asked to un-park,
204/// which is correct exactly because nothing is dispatched after it fires.
205/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
206/// that parks for an interrupted task must go on to run other tasks
207/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
208/// reusing the daemon-wide one.
209#[derive(Debug, Clone, Default)]
210pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
211
212impl Pause {
213    /// A pause nobody has asked for yet.
214    #[must_use]
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// Ask the run to park at its next node boundary. Idempotent.
220    pub fn park(&self) {
221        self.0.store(true, Ordering::SeqCst);
222    }
223
224    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
225    /// fold into the run's own `park` event - so an operator reading the run
226    /// later knows this was a deliberate interrupt rather than a shutdown or
227    /// a binary swap. The first reason recorded wins; a park already in
228    /// flight is not relabelled by a second, unrelated request.
229    pub fn park_because(&self, reason: impl Into<String>) {
230        let mut reason_guard = self
231            .1
232            .lock()
233            .unwrap_or_else(std::sync::PoisonError::into_inner);
234        if reason_guard.is_none() {
235            *reason_guard = Some(reason.into());
236        }
237        drop(reason_guard);
238        self.park();
239    }
240
241    /// Has a park been asked for?
242    #[must_use]
243    pub fn parked(&self) -> bool {
244        self.0.load(Ordering::SeqCst)
245    }
246
247    /// Why the park was asked for, when the caller used [`Pause::park_because`].
248    #[must_use]
249    pub fn reason(&self) -> Option<String> {
250        self.1
251            .lock()
252            .unwrap_or_else(std::sync::PoisonError::into_inner)
253            .clone()
254    }
255}
256
257/// Drives one run.
258pub struct Runner {
259    /// Run state; public so the CLI can report on it.
260    pub state: RunState,
261    roles: ResolvedRoles,
262    sem: Arc<Semaphore>,
263    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
264    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
265    /// this is never the same handle as `interrupt`.
266    pause: Pause,
267    /// Set when `magi serve`'s interrupt scheduler wants this specific run
268    /// parked at its next node boundary, to let a task marked
269    /// [`crate::queue::Task::interrupt`] run alone before this one carries
270    /// on. Unlike `pause`, a fresh, unshared handle per run - see
271    /// [`Runner::watch_interrupt`].
272    interrupt: Pause,
273}
274
275/// Where the branch's own commits start: its merge base with the base branch
276/// as the remote has it now, else with the recorded `base_commit`. A branch
277/// rebased onto a base that moved past `base_commit` would otherwise count
278/// the base's commits as its own under `base_commit..branch`.
279async fn review_base(
280    repo: &Path,
281    remote: &str,
282    base_branch: &str,
283    base_commit: &str,
284    branch: &str,
285) -> String {
286    review_base_checked(repo, remote, base_branch, base_commit, branch)
287        .await
288        .0
289}
290
291/// [`review_base`] plus whether the base was read from a freshly fetched
292/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
293/// never older than `base_commit`'s view of the base), but the answer is then
294/// not trusted to rewrite a pull request's title.
295async fn review_base_checked(
296    repo: &Path,
297    remote: &str,
298    base_branch: &str,
299    base_commit: &str,
300    branch: &str,
301) -> (String, bool) {
302    let tracking = format!("{remote}/{base_branch}");
303    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
304    if git::rev_exists(repo, &tracking).await
305        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
306        && !mb.is_empty()
307    {
308        return (mb, fresh);
309    }
310    let mb = git::merge_base(repo, base_commit, branch)
311        .await
312        .ok()
313        .filter(|mb| !mb.is_empty())
314        .unwrap_or_else(|| base_commit.to_owned());
315    (mb, false)
316}
317
318/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
319/// when git cannot say or finds nothing: a stale list is better than a wrong
320/// or empty one.
321pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
322    if !is_review_run(state) {
323        return;
324    }
325    let base = review_base(
326        &state.repo,
327        &state.config.merge.remote,
328        &state.base_branch,
329        &state.base_commit,
330        branch,
331    )
332    .await;
333    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
334        && !subjects.is_empty()
335        && state.reviewed_commits.as_ref() != Some(&subjects)
336    {
337        state.reviewed_commits = Some(subjects);
338        state.save().ok();
339    }
340}
341
342/// Subjects of the base's commits between the recorded start and the branch's
343/// merge base: what a stale `base_commit..branch` would have mistaken for the
344/// branch's own work.
345async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
346    let (base, trusted) = review_base_checked(
347        &state.repo,
348        &state.config.merge.remote,
349        &state.base_branch,
350        &state.base_commit,
351        branch,
352    )
353    .await;
354    if !trusted {
355        return None;
356    }
357    git::subjects(&state.repo, &state.base_commit, &base)
358        .await
359        .ok()
360}
361
362/// May an adopted pull request's title be replaced with `computed`? Only when
363/// it is empty, magi's own shape, or a base commit's subject that leaked in;
364/// a title a person wrote stays. Never when `computed` is itself a leak.
365pub(crate) fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
366    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
367    if is_leak(computed) {
368        return false;
369    }
370    let cur = current.trim();
371    cur.is_empty()
372        || cur.starts_with(REVIEW_PROMPT_OPENING)
373        || cur.starts_with("chore: land candidate")
374        || cur.starts_with("magi: candidate")
375        || is_leak(cur)
376}
377
378/// The commit a run branches from: the base branch as the remote has it.
379///
380/// Two failures this replaces. A run used to branch off `HEAD` and so refused
381/// to start on a dirty tree, which made `magi serve` decline every task for as
382/// long as the operator had work in progress - most of the time. Branching off
383/// the *local* base branch fixed that and introduced a worse one: `land` merges
384/// the winner on GitHub, nothing updates the local ref, and the next run
385/// branches off a base missing everything the previous runs landed. Two tasks
386/// in a row from a phone would have had the second silently re-implementing
387/// against stale code and opening a pull request that reverted the first.
388///
389/// Only refs move here - no checkout, no local branch, no merge - so it is safe
390/// with uncommitted work in the tree. A machine with no network still starts:
391/// the fetch may fail and the local tip is used with a warning, because
392/// refusing to run offline is a worse failure than running against a base the
393/// operator can see for themselves.
394///
395/// One function, called by both entry points. Two answers to "where does a run
396/// branch from" is the kind of drift nobody notices until a diff is wrong.
397/// Bring the local `branch` in line with `<remote>/<branch>` before a review
398/// checks it out.
399///
400/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
401/// anything other than plain `git push` from this checkout (a jj colocated
402/// workspace, another clone) moves only the remote-tracking ref - so the local
403/// one can be a stale placeholder. It moves only when local is behind the remote or is an
404/// empty placeholder that diverged from it; unpushed local work is kept, and a real
405/// divergence is refused rather than guessed at.
406async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
407    let tracking = format!("{remote}/{branch}");
408    let fetched = git::fetch(repo, remote, branch).await;
409    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
410    let local_exists = git::branch_exists(repo, branch).await?;
411    if !fresh {
412        if !local_exists {
413            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
414        }
415        tracing::warn!(
416            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
417        );
418        return Ok(());
419    }
420    let remote_sha = git::rev_parse(repo, &tracking).await?;
421    if !local_exists {
422        git::git(repo, &["branch", branch, &tracking]).await?;
423        return Ok(());
424    }
425    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
426    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
427        return Ok(());
428    }
429    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
430        // Diverged. `reconcile` settles it only when it can prove nothing is
431        // lost: a local tip that is the remote's change rebased is pushed over
432        // it (lease pinned to the tip read here), a tip whose every extra
433        // commit is empty is a placeholder the remote's work replaced, and
434        // anything else is two different changes - a question for a person.
435        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
436            .await?
437        {
438            crate::reconcile::Reconciliation::Pushed => {
439                tracing::warn!(
440                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
441                    short(&local_sha),
442                    short(&remote_sha)
443                );
444                return Ok(());
445            }
446            crate::reconcile::Reconciliation::Placeholder => {}
447            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
448        }
449    }
450    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
451    if !out.ok() {
452        bail!(
453            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
454            short(&local_sha),
455            short(&remote_sha),
456            out.stderr
457        );
458    }
459    tracing::warn!(
460        "local `{branch}` was stale: fast-forwarded {} -> {}",
461        short(&local_sha),
462        short(&remote_sha)
463    );
464    Ok(())
465}
466
467async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
468    let tracking = format!("{remote}/{base_branch}");
469    let fetched = git::fetch(repo, remote, base_branch).await;
470    if let Ok(out) = &fetched
471        && out.ok()
472        && git::rev_exists(repo, &tracking).await
473    {
474        return git::rev_parse(repo, &tracking).await;
475    }
476    let why = match &fetched {
477        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
478        Ok(_) => format!("{remote} has no {base_branch}"),
479        Err(e) => e.to_string(),
480    };
481    // No fallback to the local branch: it may be behind, and branching off an
482    // old commit is the stale-checkout bug this check exists to prevent.
483    bail!(
484        "cannot read {tracking} ({why}); refusing to branch off the local \
485         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
486         base / remote in magi.toml"
487    )
488}
489
490/// Exclusive claim on one run's `magi fix` step, released on drop — including
491/// on an early return or a panic.
492///
493/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
494/// manual `magi fix` invocations against the same run are otherwise
495/// invisible to each other and would race to remove and recreate the same
496/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
497/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
498/// which is only ever reclaimed later, out of band, by
499/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
500/// `magi fix` invocation is not necessarily running under either of those, so
501/// nothing would ever sweep a lock a killed or crashed process left behind.
502/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
503/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
504/// lease use: an unreadable or unparsable pid, or a liveness query the
505/// platform cannot answer, reads as alive and the lock is left in place.
506struct FixClaim {
507    path: PathBuf,
508}
509
510impl FixClaim {
511    fn acquire(dir: &Path) -> Result<Self> {
512        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
513        let path = dir.join("fix.lock");
514        match Self::create(&path) {
515            Ok(claim) => Ok(claim),
516            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
517                if Self::reclaim_if_dead(&path) {
518                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
519                } else {
520                    bail!(
521                        "another `magi fix` is already running for this run ({} exists)",
522                        path.display()
523                    )
524                }
525            }
526            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
527        }
528    }
529
530    fn create(path: &Path) -> std::io::Result<Self> {
531        let mut f = std::fs::OpenOptions::new()
532            .write(true)
533            .create_new(true)
534            .open(path)?;
535        use std::io::Write as _;
536        // Read back by `reclaim_if_dead` on a later, stuck invocation.
537        writeln!(f, "{}", std::process::id())?;
538        Ok(Self {
539            path: path.to_owned(),
540        })
541    }
542
543    /// True if the lock named a process confirmed dead, in which case it was
544    /// also removed. Never true on an unreadable file, an unparsable pid, or
545    /// a liveness query the platform cannot answer — see this type's own doc.
546    fn reclaim_if_dead(path: &Path) -> bool {
547        let dead = std::fs::read_to_string(path)
548            .ok()
549            .and_then(|body| body.trim().parse::<u32>().ok())
550            .is_some_and(|pid| !crate::proc::pid_alive(pid));
551        dead && std::fs::remove_file(path).is_ok()
552    }
553}
554
555impl Drop for FixClaim {
556    fn drop(&mut self) {
557        let _ = std::fs::remove_file(&self.path);
558    }
559}
560
561impl Runner {
562    /// Start a fresh run against `repo`.
563    pub async fn start(
564        repo: &Path,
565        instruction: String,
566        config: Config,
567        origin: Origin,
568    ) -> Result<Self> {
569        Self::start_naming(repo, instruction, "", config, origin).await
570    }
571
572    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
573    /// searched for branch and commit references along with the instruction,
574    /// since a task may name the work it is about only in its title.
575    pub async fn start_naming(
576        repo: &Path,
577        instruction: String,
578        also_scan: &str,
579        config: Config,
580        origin: Origin,
581    ) -> Result<Self> {
582        let repo = git::toplevel(repo).await?;
583        let missing = agent::missing_programs(&config.agents);
584        if !missing.is_empty() {
585            bail!(
586                "these agent programs are not on PATH: {}. Fix the roster in \
587                 magi.toml or install them.",
588                missing.join(", ")
589            );
590        }
591        let base_branch =
592            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
593                .await?;
594        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
595        let roles = config.resolve_roles()?;
596        let max_parallel = config.graph.max_parallel.max(1);
597        // A task that points at work already in the repository starts from
598        // it; what the repository says about each reference is recorded.
599        let seeds = refs::resolve(
600            &repo,
601            &base_commit,
602            &config.merge.remote,
603            &format!("{also_scan}\n{instruction}"),
604        )
605        .await;
606        refs::plan(&repo, &seeds).await?;
607        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
608        // Recorded before the first save, so a crash right after minting
609        // cannot leave a run with no origin. Legibility only: nothing reads it
610        // to decide anything.
611        state.origin = Some(origin);
612        for seed in &seeds {
613            state.event(
614                "seed",
615                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
616            );
617        }
618        state.seeds = seeds;
619        state.event("start", format!("run {} created", state.id));
620        state.save()?;
621        Ok(Self {
622            state,
623            roles,
624            sem: Arc::new(Semaphore::new(max_parallel)),
625            pause: Pause::new(),
626            interrupt: Pause::new(),
627        })
628    }
629
630    /// Open a review-only run against work that already exists on `branch`.
631    ///
632    /// The expensive half of the graph is the implement wave — measured at
633    /// 111 and 134 internal tool-loop turns on this repository, against a
634    /// handful for a judge or a reviewer. The cheap half is worth running on
635    /// hand-written work too, and there was no way to reach it.
636    ///
637    /// No new state and no schema change are needed: a run with **one** viable
638    /// candidate and a tally already decided degrades `execute` to exactly
639    /// review → gate → merge, because `judge` skips a single-candidate field,
640    /// `deliberate` has fewer than two first choices to reconcile, `vote`
641    /// returns early, `tally` is already present and `fold_losers` has no
642    /// losers. Resuming such a run therefore does the right thing as well.
643    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
644        Self::review_taking_over(repo, branch, config, None, origin).await
645    }
646
647    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
648    /// at the same task still has `branch` checked out, its worktree is
649    /// released first if that is safe (see [`crate::handover`]), and the
650    /// review refuses with the reason if it is not. `None` is a hand-run
651    /// review: it has no earlier attempts, so only a worktree of a dead run
652    /// magi recorded itself can be released.
653    pub async fn review_taking_over(
654        repo: &Path,
655        branch: &str,
656        config: Config,
657        takeover: Option<crate::handover::Takeover>,
658        origin: Origin,
659    ) -> Result<Self> {
660        let repo = git::toplevel(repo).await?;
661        let missing = agent::missing_programs(&config.agents);
662        if !missing.is_empty() {
663            bail!(
664                "these agent programs are not on PATH: {}. Fix the roster in \
665                 magi.toml or install them.",
666                missing.join(", ")
667            );
668        }
669        let base_branch =
670            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
671                .await?;
672        if base_branch == branch {
673            bail!("`{branch}` is the base branch; there is nothing to review against");
674        }
675        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
676
677        let roles = config.resolve_roles()?;
678        let max_parallel = config.graph.max_parallel.max(1);
679        let mut state = RunState::new(
680            repo.clone(),
681            base_branch,
682            base_commit.clone(),
683            String::new(),
684            config,
685        );
686        state.origin = Some(origin);
687
688        // Released before anything else touches the branch: a stale local
689        // branch is moved with `git branch -f`, which git refuses while an
690        // earlier attempt's worktree still has it checked out. Everything
691        // after this point that can fail puts the old run back.
692        // A hand-run review has no task, hence no earlier attempts, but a
693        // worktree of a dead run magi made may still be released.
694        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
695            earlier: Vec::new(),
696            home: crate::run::home(),
697            choice: None,
698        });
699        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
700        if let Some(released) = &released {
701            state.event(
702                "release",
703                format!(
704                    "took `{branch}` over from run {}: its worktree was released: {}",
705                    crate::run::short_of(&released.old_id),
706                    released.audit
707                ),
708            );
709        }
710        // The owner's answer to an earlier divergence question is applied
711        // here: after the release (git will not move a checked-out branch)
712        // and before the sync that would otherwise ask again.
713        if let Some(choice) = takeover.choice.as_ref()
714            && let Err(e) =
715                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
716                    .await
717        {
718            if let Some(released) = &released {
719                released.restore(&repo, branch).await;
720            }
721            return Err(e.context("applying the owner's answer about the diverged branch"));
722        }
723        // An earlier attempt of this task that published the task head, and
724        // whose work is the branch under review, hands that head over: the
725        // review run updates the same pull request. Without one (a legacy
726        // `magi/<run>/<label>` PR, a hand-named branch) the branch keeps its
727        // own head and its own open pull request is adopted as before.
728        for id in takeover.earlier.iter().rev() {
729            if let Ok(old) = RunState::load_under(id, &takeover.home)
730                && let Some(head) = old.pr_head.clone()
731                && (head == branch || old.winner().is_some_and(|w| w.branch == branch))
732            {
733                state.pr_head = Some(head);
734                break;
735            }
736        }
737        let opened =
738            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
739        if opened.is_err()
740            && let Some(released) = &released
741        {
742            released.restore(&repo, branch).await;
743        }
744        opened
745    }
746
747    /// The half of [`Runner::review_taking_over`] that can fail after an
748    /// earlier attempt's worktree was released.
749    async fn open_review(
750        repo: &Path,
751        branch: &str,
752        mut state: RunState,
753        roles: ResolvedRoles,
754        max_parallel: usize,
755        base_commit: String,
756    ) -> Result<Self> {
757        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
758        // The commit subjects are the closest thing to a task statement that
759        // existing work carries, and the reviewers are told as much.
760        let start = review_base(
761            repo,
762            &state.config.merge.remote,
763            &state.base_branch,
764            &base_commit,
765            branch,
766        )
767        .await;
768        let log = git::log_oneline(repo, &start, branch)
769            .await
770            .unwrap_or_default();
771        let instruction = format!(
772            "Review the work already on branch `{branch}`. There is no task \
773             statement: what the change claims to do is whatever its commits \
774             say.\n\n{}",
775            if log.trim().is_empty() {
776                "(no commit messages)"
777            } else {
778                log.trim()
779            }
780        );
781        state.instruction = instruction;
782        state.reviewed_commits = Some(
783            git::subjects(repo, &start, branch)
784                .await
785                .unwrap_or_default(),
786        );
787
788        // An attached worktree, so the fixer's commits land on the branch under
789        // review rather than on a detached head nobody will look at again.
790        let worktree = state.worktree_root().join("under-review");
791        if let Some(parent) = worktree.parent() {
792            tokio::fs::create_dir_all(parent).await.ok();
793        }
794        let path = worktree.to_string_lossy().to_string();
795        git::git(repo, &["worktree", "add", &path, branch])
796            .await
797            .with_context(|| {
798                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
799            })?;
800
801        let setup = match ensure_setup_config(&mut state, repo).await {
802            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
803            Err(e) => Err(e),
804        };
805        if let Err(e) = setup {
806            git::worktree_remove(repo, &worktree).await.ok();
807            return Err(e);
808        }
809        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
810            .await
811            .unwrap_or(0);
812        if commits == 0 {
813            git::worktree_remove(repo, &worktree).await.ok();
814            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
815        }
816        let files = git::changed_files(&worktree, &base_commit, "HEAD")
817            .await
818            .map(|f| f.len())
819            .unwrap_or(0);
820        if files == 0
821            && let (Ok(head_tree), Ok(base_tree)) = (
822                git::tree_of(&worktree, "HEAD").await,
823                git::tree_of(&worktree, &base_commit).await,
824            )
825            && head_tree == base_tree
826        {
827            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
828            git::worktree_remove(repo, &worktree).await.ok();
829            bail!(
830                "`{branch}` at {} has a tree identical to base {}; this usually means \
831                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
832                 against `{}/{branch}`) rather than an empty change",
833                short(&head),
834                short(&base_commit),
835                state.config.merge.remote
836            );
837        }
838        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
839            .await
840            .unwrap_or_default();
841
842        state.candidates.push(Candidate {
843            index: 0,
844            label: 'A',
845            // Not an agent id on purpose: nothing in the roster wrote this, and
846            // the stats tables must not credit anyone with a win for it.
847            agent: EXISTING_BRANCH.to_owned(),
848            branch: branch.to_owned(),
849            worktree,
850            summary: String::new(),
851            stat,
852            files,
853            commits,
854            empty: false,
855            failed: None,
856            verified_noop: None,
857            duration_ms: 0,
858            folded: false,
859        });
860        state.tally = Some(Tally {
861            first_choice: BTreeMap::from([('A', 0)]),
862            borda: BTreeMap::new(),
863            winner: 'A',
864            rankings: 0,
865            unanimous_initial: false,
866            deliberated: false,
867            changed_votes: 0,
868            unanimous_final: false,
869            tie_break: None,
870            // No panel sat, so no quorum applies. Zero judges is the correct
871            // number for work that never competed, and must not be reported as
872            // a collapsed panel.
873            judges: 0,
874            present: 0,
875            quorum: 0,
876            met_quorum: true,
877            uncontested: Some("review-only run: nothing competed".to_owned()),
878        });
879        state.status = RunStatus::Reviewing;
880        state.event(
881            "start",
882            format!(
883                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
884                state.id
885            ),
886        );
887        state.save()?;
888        Ok(Self {
889            state,
890            roles,
891            sem: Arc::new(Semaphore::new(max_parallel)),
892            pause: Pause::new(),
893            interrupt: Pause::new(),
894        })
895    }
896
897    /// Reopen an existing run.
898    pub fn resume(id: &str) -> Result<Self> {
899        let state = RunState::load(id)?;
900        if let Some(to) = &state.released_to {
901            bail!(
902                "run {} cannot be resumed: its worktree was released to run {}",
903                state.short(),
904                crate::run::short_of(to)
905            );
906        }
907        let roles = state.config.resolve_roles()?;
908        let max_parallel = state.config.graph.max_parallel.max(1);
909        Ok(Self {
910            state,
911            roles,
912            sem: Arc::new(Semaphore::new(max_parallel)),
913            pause: Pause::new(),
914            interrupt: Pause::new(),
915        })
916    }
917
918    /// Walk the graph to a terminal state, skipping nodes already recorded.
919    ///
920    /// Every way a run is driven - the queue loop, `magi run`, a resume from
921    /// the phone - ends here, so this is the one place a run that ended
922    /// Blocked / Stalled / Failed, or died with an error, is announced to the
923    /// notification centre. Best-effort: see [`crate::notices::raise`].
924    pub async fn execute(&mut self) -> Result<()> {
925        let result = self.execute_graph().await;
926        self.mark_driver_exited();
927        let ended = if result.is_err() {
928            Some(crate::notices::run_stopped(&self.state.id, &self.state))
929        } else {
930            crate::notices::run_ended(&self.state)
931        };
932        if let Some(notice) = ended {
933            crate::notices::raise(notice);
934        }
935        result
936    }
937
938    /// Record that this process no longer drives the run, so its pid (a
939    /// daemon's outlives the run) is not read as a live driver.
940    ///
941    /// Written onto the record as it is on disk, never this copy: another
942    /// process may have resumed the run (recording its own pid and clearing
943    /// the flag) or released its worktree since this copy was read, and
944    /// saving over that would mark a running driver dead. Only a record still
945    /// naming this process as the driver is touched.
946    fn mark_driver_exited(&mut self) {
947        self.state.driver_exited = true;
948        let pid = std::process::id();
949        let Ok(_lock) = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id) else {
950            return;
951        };
952        let Ok(mut disk) = RunState::load(&self.state.id) else {
953            return;
954        };
955        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
956            return;
957        }
958        disk.driver_exited = true;
959        if let Err(e) = disk.save() {
960            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
961        }
962    }
963
964    /// Validate ownership and persist the driver claim in one critical section.
965    fn claim_driver(&mut self) -> Result<()> {
966        let _lock = crate::pr_head::owner_lock(&crate::run::home(), &self.state.id)?;
967        let disk = RunState::load(&self.state.id)?;
968        crate::pr_head::ensure_owner(&disk)?;
969        // Moving again, so it is no longer parked. Set before the walk rather
970        // than in `resume`, so every way of re-entering the graph clears it
971        // and a card cannot claim a run is waiting to be resumed while the
972        // agents are already working.
973        self.state.parked = false;
974        // Any seat this state still lists as answering belongs to whatever
975        // process last drove this run — this one included, if it crashed
976        // mid-wave. Cleared and flushed immediately, before anything else
977        // runs, so a resume can never show a seat as live when nothing is
978        // asking it anything yet; the node that actually dispatches the next
979        // wave repopulates it.
980        self.state.clear_active();
981        // Recorded in the same spot, and flushed together with the clear
982        // above: this is the pid a reader checks (`RunState::liveness`) when
983        // no daemon claim exists to answer "is a process still driving this
984        // run" — a plain `magi run` / `magi review` typed into a terminal
985        // claims nothing there. Always overwritten, never only-if-absent, so
986        // a resumed run's stale pid from a previous, possibly-dead process
987        // can never survive into this one's own report. Unlike
988        // `clear_active`, this changes on every single `execute()` call, so
989        // the save below is now unconditional rather than only-if-cleared.
990        //
991        // `driver_started_at` is recorded in the same breath, from this same
992        // pid, so `liveness` can tell a live pid that is genuinely still us
993        // apart from one the OS has since handed to an unrelated process —
994        // see that field's own doc for why the pid alone is not enough.
995        // A resume that raced a takeover: the record on disk says the worktree
996        // was handed to a later run after this copy was read. Saving over it
997        // would erase that and drive a run with nothing to run in.
998        if let Some(to) = &disk.released_to {
999            bail!(
1000                "run {} cannot continue: its worktree was released to run {}",
1001                self.state.short(),
1002                crate::run::short_of(to)
1003            );
1004        }
1005        let pid = std::process::id();
1006        self.state.driver_pid = Some(pid);
1007        self.state.driver_started_at = crate::proc::process_started_at(pid);
1008        self.state.driver_exited = false;
1009        self.state.save()?;
1010        Ok(())
1011    }
1012
1013    async fn execute_graph(&mut self) -> Result<()> {
1014        self.claim_driver()?;
1015        // Publication already passed review and gate. Resume only that node:
1016        // syncing to a newer base here could change the verified commit.
1017        if self.state.pr_publication_pending {
1018            self.merge().await?;
1019            self.state.save()?;
1020            self.settle_questions();
1021            return Ok(());
1022        }
1023        // A run that already lost its quorum never resumes into the verdict
1024        // machinery: `deliberate` and `vote` would otherwise clobber the
1025        // stalled marker back to Voting and the run would keep going past a
1026        // verdict that is no longer trustworthy. Everything already recorded is
1027        // kept, so the run stays resumable (or foldable) for a human to pick up.
1028        //
1029        // On --resume the run gets one chance to repair itself: the seats a
1030        // rate limit took out are re-asked. If their quota has since reset and
1031        // the quorum is restored, the run picks up and finishes; otherwise it
1032        // stays stale and still-resumable for a later retry. If it does not
1033        // recover, the returned status stays `Stalled` and nothing was
1034        // clobbered (the recovery only mutates entries for the lost seats).
1035        if self.state.status == RunStatus::Stalled {
1036            if self.recover_stall().await? {
1037                self.finish_after_tally().await?;
1038            } else {
1039                // Still below quorum: persist the marker and stay resumable.
1040                self.state.save()?;
1041            }
1042            return Ok(());
1043        }
1044        // A run parked inside `land` - watching CI, mid fix-round, or
1045        // waiting on the owner's merge approval - resumes directly into it,
1046        // never back through `prep`. Everything before `merge` already
1047        // concluded; that is the only way `status` reaches `Landing` in the
1048        // first place. Re-walking `review_loop` first would also be actively
1049        // wrong: its own status recomputation (see its doc) treats any
1050        // clean round as reason to set `status` to `Gating`, which would
1051        // clobber this marker before `merge` ever ran, and this run would
1052        // never find its way back into `land` at all.
1053        if self.state.status == RunStatus::Landing {
1054            self.run_land().await?;
1055            // `run_land` may have settled the run right here - CI came back
1056            // green and the PR merged, say - without ever passing back
1057            // through `merge`'s own trailing call. Whatever it left `status`
1058            // as is what this has to read.
1059            self.settle_questions();
1060            return Ok(());
1061        }
1062        // A run parked at `merge` on the owner's word about a withheld PR text
1063        // resumes straight into `merge`. Re-walking the tail would sync to the
1064        // base again (possibly rebasing, possibly spending the fixer) after the
1065        // gate had passed, and then push a tree nobody reviewed or gated.
1066        if self.state.github_text.is_some()
1067            && self.state.merge.is_none()
1068            && self.state.status == RunStatus::Gating
1069        {
1070            self.merge().await?;
1071            self.state.save()?;
1072            return Ok(());
1073        }
1074        self.prep().await?;
1075        if self.park_here()? {
1076            return Ok(());
1077        }
1078        self.advise().await?;
1079        if self.park_here()? {
1080            return Ok(());
1081        }
1082        self.implement().await?;
1083        if self.park_here()? {
1084            return Ok(());
1085        }
1086        // `after_implement` already saved the state and settled any open
1087        // questions when it set this; nothing later in the graph has
1088        // anything to judge.
1089        if self.state.status == RunStatus::VerifiedNoop {
1090            return Ok(());
1091        }
1092        self.judge().await?;
1093        if self.park_here()? {
1094            return Ok(());
1095        }
1096        self.deliberate().await?;
1097        if self.park_here()? {
1098            return Ok(());
1099        }
1100        self.vote().await?;
1101        if self.park_here()? {
1102            return Ok(());
1103        }
1104        self.tally()?;
1105        // A verdict that lost its quorum is not trustworthy: do not review,
1106        // gate, or merge on it. Everything already done is kept, so the run
1107        // stays resumable (or foldable); the human can replace the agent that
1108        // ran out of quota and pick it up.
1109        if self.state.status == RunStatus::Stalled {
1110            // Persist the stalled marker now — the normal end-of-execute save
1111            // below is below this early return, and without it a resumed run
1112            // would reload a pre-tally status and keep going.
1113            self.state.save()?;
1114            return Ok(());
1115        }
1116        self.finish_after_tally().await?;
1117        Ok(())
1118    }
1119
1120    /// Park here if asked to, recording it in the run's own timeline.
1121    ///
1122    /// Returns whether the caller should stop walking the graph. The state is
1123    /// saved either way by the node that just finished; this adds the event so
1124    /// the operator's card says why a run that is neither finished nor moving
1125    /// is sitting where it is.
1126    fn park_here(&mut self) -> Result<bool> {
1127        // Either handle asking is enough - see `Pause`'s own doc for why
1128        // they are never the same one. `interrupt` is checked second so a
1129        // reason it carries is preferred in the message below over a plain
1130        // shutdown park racing it at the same boundary.
1131        if !self.pause.parked() && !self.interrupt.parked() {
1132            return Ok(false);
1133        }
1134        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1135            Some(reason) => format!(
1136                "parked after `{}` ({reason}) — resume to carry on from here",
1137                self.state.status.as_str()
1138            ),
1139            None => format!(
1140                "parked after `{}` — resume to carry on from here",
1141                self.state.status.as_str()
1142            ),
1143        };
1144        self.state.event("park", why);
1145        self.state.parked = true;
1146        self.state.save()?;
1147        Ok(true)
1148    }
1149
1150    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1151    pub fn on_pause(&mut self, pause: Pause) {
1152        self.pause = pause;
1153    }
1154
1155    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1156    /// scheduler asking this one run - and no other - to park so a task
1157    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1158    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1159    /// handle.
1160    pub fn watch_interrupt(&mut self, pause: Pause) {
1161        self.interrupt = pause;
1162    }
1163
1164    /// Abandon this run's own open questions, once `status` has actually
1165    /// settled rather than merely paused.
1166    ///
1167    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1168    /// either back up with the candidates, the review round and the seat
1169    /// sessions already on disk, so a question an implementer asked mid-round
1170    /// may still get a real answer read by a real resume. Only the statuses
1171    /// `resumable` excludes are actually final: the run merged, it reached
1172    /// `Ready` with nothing left to do, it failed outright with no
1173    /// established point to continue from, or every candidate agreed, with
1174    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1175    /// every one of those the seat that asked is gone for good, exactly like
1176    /// the run being deleted under `magi run rm` - so the same cleanup
1177    /// applies, worded for what actually happened instead of "the run was
1178    /// deleted".
1179    ///
1180    /// Best-effort and silent on success: called from every place `status`
1181    /// can land on one of those three, including ones a resumed run revisits,
1182    /// so it must cost nothing when there was nothing open to begin with.
1183    fn settle_questions(&mut self) {
1184        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1185            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1186        }
1187    }
1188
1189    /// The tail of the graph after a trustworthy tally: fold losers, review,
1190    /// gate, merge, and persist.
1191    async fn finish_after_tally(&mut self) -> Result<()> {
1192        // Before the losers are folded: their branches are what the graft
1193        // reads from.
1194        self.graft().await?;
1195        self.fold_losers().await?;
1196        // Before review starts, and again right before the gate: a run's
1197        // review rounds can themselves take long enough for the base to move
1198        // a second time, and the gate is the one node whose "green" gets
1199        // acted on.
1200        self.sync_to_base().await?;
1201        if self.state.status == RunStatus::AlreadyInBase {
1202            return Ok(());
1203        }
1204        self.review_loop().await?;
1205        self.sync_to_base().await?;
1206        if self.state.status == RunStatus::AlreadyInBase {
1207            return Ok(());
1208        }
1209        self.gate().await?;
1210        self.merge().await?;
1211        self.state.save()?;
1212        Ok(())
1213    }
1214
1215    // ---------------------------------------------------------------- prep
1216
1217    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1218    /// run with the step and its output in the event; it is never swallowed.
1219    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1220    /// resume starts over instead of finding candidates and missing seats.
1221    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1222        let result = match ensure_setup_config(&mut self.state, repo).await {
1223            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1224            Err(e) => Err(e),
1225        };
1226        if let Err(e) = result {
1227            git::worktree_remove(repo, wt).await.ok();
1228            if in_prep {
1229                self.rollback_prep(repo).await;
1230            }
1231            self.state.status = RunStatus::Blocked;
1232            self.state.event(
1233                "setup",
1234                format!("worktree setup failed in {}: {e:#}", wt.display()),
1235            );
1236            self.state.save()?;
1237            return Err(e);
1238        }
1239        Ok(())
1240    }
1241
1242    /// Undo everything `prep` created, so a resume re-runs it whole.
1243    async fn rollback_prep(&mut self, repo: &Path) {
1244        let root = self.state.worktree_root();
1245        for c in std::mem::take(&mut self.state.candidates) {
1246            git::worktree_remove(repo, &c.worktree).await.ok();
1247            git::branch_delete(repo, &c.branch).await.ok();
1248        }
1249        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1250        for j in 1..=seats {
1251            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1252                .await
1253                .ok();
1254        }
1255        for k in 1..=self.state.config.graph.advisors {
1256            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1257                .await
1258                .ok();
1259        }
1260    }
1261
1262    async fn prep(&mut self) -> Result<()> {
1263        if !self.state.candidates.is_empty() {
1264            return Ok(());
1265        }
1266        self.state.status = RunStatus::Prep;
1267        let repo = self.state.repo.clone();
1268        let base = self.state.base_commit.clone();
1269        let plan = refs::plan(&repo, &self.state.seeds).await?;
1270        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1271        let root = self.state.worktree_root();
1272        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1273
1274        // The hook is the write-time half of the blindness contract; the
1275        // presentation filter in `blind` is the half that cannot be bypassed.
1276        let hooks_dir = self.state.dir().join("hooks");
1277        if self.state.config.blind.commit_msg_hook {
1278            std::fs::create_dir_all(&hooks_dir)
1279                .with_context(|| format!("create {}", hooks_dir.display()))?;
1280            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1281            let path = hooks_dir.join("commit-msg");
1282            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1283            make_executable(&path)?;
1284            // Ref-counted rather than a plain idempotent set: with more than
1285            // one run able to be in flight in the same repository at once
1286            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1287            // true?" check cannot tell "another run of mine still needs
1288            // this" from "nobody does", and the run that happens to finish
1289            // first would disable the hook out from under a sibling still
1290            // relying on it.
1291            git::acquire_worktree_config(&repo).await?;
1292            self.state.enabled_worktree_config = true;
1293        }
1294
1295        // `[worktree] setup` hides its products per worktree, which needs the
1296        // same per-worktree config the hook does. Held until fold, like it.
1297        ensure_setup_config(&mut self.state, &repo).await?;
1298
1299        for (index, (spec, label)) in self
1300            .roles
1301            .implementers
1302            .clone()
1303            .into_iter()
1304            .zip(labels)
1305            .enumerate()
1306        {
1307            let branch = self.state.branch_for(label);
1308            let worktree = root.join(format!("cand-{label}"));
1309            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1310            if self.state.config.blind.commit_msg_hook {
1311                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1312            }
1313            git::local_exclude(&worktree, "/.magi/").await?;
1314            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1315                // Nothing of this prep may survive: a resume must start over,
1316                // not find half the candidates and skip the rest.
1317                git::worktree_remove(&repo, &worktree).await.ok();
1318                git::branch_delete(&repo, &branch).await.ok();
1319                self.rollback_prep(&repo).await;
1320                self.state.status = RunStatus::Blocked;
1321                self.state
1322                    .event("prep", format!("worktree setup failed: {e:#}"));
1323                self.state.save()?;
1324                return Err(e);
1325            }
1326            for pick in &plan.picks {
1327                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1328                    self.state.status = RunStatus::Blocked;
1329                    self.state
1330                        .event("prep", format!("cannot apply referenced commit: {e}"));
1331                    self.state.save()?;
1332                    return Err(e);
1333                }
1334            }
1335            self.state.candidates.push(Candidate {
1336                index,
1337                label,
1338                agent: spec.id.clone(),
1339                branch,
1340                worktree,
1341                summary: String::new(),
1342                stat: String::new(),
1343                files: 0,
1344                commits: 0,
1345                empty: false,
1346                failed: None,
1347                verified_noop: None,
1348                duration_ms: 0,
1349                folded: false,
1350            });
1351        }
1352
1353        for j in 1..=self.roles.judges.len() {
1354            let wt = root.join(format!("judge-{j}"));
1355            if !wt.exists() {
1356                git::worktree_add_detached(&repo, &wt, &base).await?;
1357                self.setup_seat_worktree(&repo, &wt, true).await?;
1358            }
1359        }
1360
1361        // Disposable, detached checkouts for the design-deliberation stage's
1362        // advisor seats — the same shape as the judges' above, at the same
1363        // base commit, since advisors also only ever read. Sized off the
1364        // configured count directly rather than a resolved roster: unlike
1365        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1366        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1367        // `prep` has no `ResolvedRoles` field to read a count from here.
1368        if self.state.config.graph.advise {
1369            for k in 1..=self.state.config.graph.advisors {
1370                let wt = root.join(format!("advisor-{k}"));
1371                if !wt.exists() {
1372                    git::worktree_add_detached(&repo, &wt, &base).await?;
1373                    self.setup_seat_worktree(&repo, &wt, true).await?;
1374                }
1375            }
1376        }
1377
1378        // A judge cannot tell it is looking at its own patch — the seats keep
1379        // separate conversations — but a panel that shares agents with the
1380        // field is less independent than it looks, and that is worth saying out
1381        // loud once per run rather than leaving it in the config.
1382        let authors: Vec<&str> = self
1383            .roles
1384            .implementers
1385            .iter()
1386            .map(|a| a.id.as_str())
1387            .collect();
1388        let overlap: Vec<String> = self
1389            .roles
1390            .judges
1391            .iter()
1392            .enumerate()
1393            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1394            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1395            .collect();
1396        if !overlap.is_empty() {
1397            let note = format!(
1398                "{} also authored a candidate; blind, but the panel is less \
1399                 independent than {} distinct agents would be",
1400                overlap.join(", "),
1401                self.roles.judges.len()
1402            );
1403            self.state.event("prep", note);
1404        }
1405
1406        self.state.event(
1407            "prep",
1408            format!(
1409                "{} candidates, {} judges, base {} ({})",
1410                self.state.candidates.len(),
1411                self.roles.judges.len(),
1412                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1413                self.state.base_branch
1414            ),
1415        );
1416        self.state.status = RunStatus::Implementing;
1417        self.state.save()?;
1418        Ok(())
1419    }
1420
1421    // -------------------------------------------------------------- advise
1422
1423    /// The design-deliberation stage: independent, read-only advisor seats
1424    /// each sketch a design before any implementer touches the repository,
1425    /// and (when at least one produced a usable proposal) a synthesis seat
1426    /// blends them into a brief `implement` carries in every candidate's
1427    /// prompt.
1428    ///
1429    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1430    /// advisors` is the proposal count. Everything here is best-effort and
1431    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1432    /// that cannot reach quota, or a synthesis seat that produced nothing
1433    /// usable all leave `implement` exactly as it was before this stage
1434    /// existed — the task instruction alone — rather than failing the whole
1435    /// competition over an enrichment stage. Every outcome is still recorded
1436    /// as an event, so a run that got nothing from this stage says why.
1437    ///
1438    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1439    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1440    /// resumed run whose stage failed would re-run it, and re-spend the
1441    /// agent calls, on every reentry before `implement`.
1442    ///
1443    /// Also skipped once any candidate shows implementation progress — the
1444    /// exact predicate `implement` itself uses to decide a candidate is no
1445    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1446    /// is not enough: a run created by an older binary that predates this
1447    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1448    /// an already-`Implementing`-or-later run under this build would
1449    /// otherwise walk straight back through `prep` (a no-op once candidates
1450    /// exist) into this node and spawn every advisor seat against worktrees
1451    /// `prep` never recreated — after implementation has already started,
1452    /// which is exactly the invariant this stage exists to guarantee.
1453    async fn advise(&mut self) -> Result<()> {
1454        let implement_untouched = self
1455            .state
1456            .candidates
1457            .iter()
1458            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1459        if !self.state.config.graph.advise || self.state.advise_attempted {
1460            return Ok(());
1461        }
1462        if !implement_untouched {
1463            self.state.event(
1464                "advise",
1465                "skipping the design-deliberation stage: at least one \
1466                 candidate already shows implementation progress, so this \
1467                 run is past the point the stage exists to run before"
1468                    .to_owned(),
1469            );
1470            self.state.advise_attempted = true;
1471            self.state.save()?;
1472            return Ok(());
1473        }
1474        let run_id = self.state.id.clone();
1475        let prompts = self.state.config.prompts.clone();
1476        let instruction = self.state.instruction.clone();
1477        let language = self.state.config.graph.language.clone();
1478        let root = self.state.worktree_root();
1479        let n = self.state.config.graph.advisors;
1480        let where_recorded = self.state.dir().join("run.json");
1481
1482        let seats = match self.state.config.advisors() {
1483            Ok(seats) if !seats.is_empty() => seats,
1484            Ok(_) => {
1485                self.state.event(
1486                    "advise",
1487                    format!(
1488                        "[graph] advisors is 0; skipping the design-deliberation \
1489                         stage and continuing without a synthesis brief (see {})",
1490                        where_recorded.display()
1491                    ),
1492                );
1493                self.state.advise_attempted = true;
1494                self.state.save()?;
1495                return Ok(());
1496            }
1497            Err(e) => {
1498                self.state.event(
1499                    "advise",
1500                    format!(
1501                        "could not resolve advisor seats ({e:#}); continuing \
1502                         without a design-deliberation brief (see {})",
1503                        where_recorded.display()
1504                    ),
1505                );
1506                self.state.advise_attempted = true;
1507                self.state.save()?;
1508                return Ok(());
1509            }
1510        };
1511
1512        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1513        let artifacts = agent::artifacts_dir(&self.state.dir());
1514        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1515
1516        let mut jobs = Vec::new();
1517        for (i, spec) in seats.iter().cloned().enumerate() {
1518            let seat_key = format!("advisor-{}", i + 1);
1519            let seat = self.seat(&seat_key, &spec.id);
1520            jobs.push(SeatJob {
1521                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1522                spec,
1523                seat,
1524                cwd: worktrees[i % worktrees.len()].clone(),
1525                timeout,
1526                allow_write: false,
1527                sessions: false,
1528                artifacts: artifacts.clone(),
1529                stem: seat_key,
1530                handover: None,
1531            });
1532        }
1533
1534        self.state.event(
1535            "advise",
1536            format!(
1537                "{} advisor seat(s) sketching a design in parallel",
1538                jobs.len()
1539            ),
1540        );
1541        let mut quota_losses = Vec::new();
1542        let cache = self.state.config.cache_dir();
1543        let ctx = WaveCtx {
1544            carry_seats: false,
1545            run: &run_id,
1546            node: "advise",
1547            prompts: &prompts,
1548            cache: cache.as_deref(),
1549            round: None,
1550        };
1551        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1552        let results = ask_json_wave::<Proposal>(
1553            jobs,
1554            Arc::clone(&self.sem),
1555            self.state.config.graph.retries,
1556            &advisor_roster,
1557            &ctx,
1558            &mut quota_losses,
1559            &mut self.state,
1560            &|p: &Proposal| p.validate(),
1561        )
1562        .await;
1563        self.state.quota.extend(quota_losses);
1564
1565        let mut records = Vec::with_capacity(results.len());
1566        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1567            let agent_id = seat.agent.clone();
1568            self.state.seats.insert(seat.key.clone(), seat);
1569            match res {
1570                Ok((proposal, out)) => {
1571                    self.state
1572                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1573                    records.push(advise::AdvisorRecord::proposed(
1574                        i + 1,
1575                        agent_id,
1576                        proposal,
1577                        out.duration_ms,
1578                    ));
1579                }
1580                Err(e) => {
1581                    self.state.event(
1582                        "advise",
1583                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1584                    );
1585                    records.push(advise::AdvisorRecord::failed(
1586                        i + 1,
1587                        agent_id,
1588                        e.to_string(),
1589                    ));
1590                }
1591            }
1592        }
1593
1594        let mut advice = advise::Advice {
1595            records,
1596            synthesis: None,
1597        };
1598        if advice.proposals().is_empty() {
1599            self.state.event(
1600                "advise",
1601                "no advisor produced a usable proposal; continuing without a \
1602                 synthesis brief"
1603                    .to_owned(),
1604            );
1605        } else {
1606            match self
1607                .synthesize_brief(
1608                    &advice,
1609                    &instruction,
1610                    &language,
1611                    &worktrees[0],
1612                    &artifacts,
1613                    &run_id,
1614                    &prompts,
1615                    cache.as_deref(),
1616                )
1617                .await
1618            {
1619                Ok(Some(text)) => {
1620                    self.state.event(
1621                        "advise",
1622                        "synthesized a design brief for the implementer".to_owned(),
1623                    );
1624                    advice.synthesis = Some(text);
1625                }
1626                Ok(None) => {
1627                    self.state.event(
1628                        "advise",
1629                        "the synthesis seat produced nothing usable; continuing \
1630                         without a design brief"
1631                            .to_owned(),
1632                    );
1633                }
1634                Err(e) => {
1635                    self.state.event(
1636                        "advise",
1637                        format!("could not synthesize a design brief: {e:#}"),
1638                    );
1639                }
1640            }
1641        }
1642        advise::apply_reflection(&mut advice);
1643
1644        self.state.advice = Some(advice);
1645        self.state.advise_attempted = true;
1646        self.state.save()?;
1647        Ok(())
1648    }
1649
1650    /// The synthesis seat: reads every advisor's proposal and blends them
1651    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1652    /// out of [`Runner::advise`] only for readability — it is not called
1653    /// anywhere else.
1654    ///
1655    /// Picked the same way [`crate::talk`]'s standing conversation and
1656    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1657    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1658    /// order (a claude seat, else the first runnable agent in roster order)
1659    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1660    /// in [`crate::config`] for why a dedicated field exists here at all.
1661    #[allow(clippy::too_many_arguments)]
1662    async fn synthesize_brief(
1663        &mut self,
1664        advice: &advise::Advice,
1665        instruction: &str,
1666        language: &str,
1667        cwd: &Path,
1668        artifacts: &Path,
1669        run_id: &str,
1670        prompts: &Prompts,
1671        cache: Option<&Path>,
1672    ) -> Result<Option<String>> {
1673        let chain = agent::pick_chain(
1674            &self.state.config.agents,
1675            self.state.config.roles.synthesizer.as_ref(),
1676            &agent::installed,
1677            "synthesizer",
1678        )?;
1679        let proposals = advice.proposals();
1680        let mut prompt = prompt::with_overlay(
1681            prompt::synthesize_brief(instruction, &proposals, language),
1682            prompts.overlay("advise"),
1683        );
1684        if cache.is_some() {
1685            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1686            // below — see `prompt::build_cache_note`'s doc for why telling a
1687            // read-only seat to build through the shared cache is exactly how
1688            // a sandbox's write refusal gets misread as a defect.
1689            prompt.push('\n');
1690            prompt.push_str(&prompt::build_cache_note("advise", false));
1691        }
1692        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1693        // Each id is tried once, in order; a quota hit, error or unusable
1694        // answer moves to the next. The seat is single-turn (`sessions:
1695        // false`) and the prompt is the whole context, so a fallback agent
1696        // needs nothing carried over.
1697        let mut last = None;
1698        for (n, spec) in chain.iter().enumerate() {
1699            if n > 0 {
1700                self.state
1701                    .event("advise", format!("synthesis falling back to {}", spec.id));
1702            }
1703            let mut seat = self.seat("advise-synthesis", &spec.id);
1704            let outcome = agent::invoke(
1705                spec,
1706                &mut seat,
1707                &Invocation {
1708                    cwd,
1709                    prompt: &prompt,
1710                    timeout,
1711                    allow_write: false,
1712                    unsandboxed: false,
1713                    sessions: false,
1714                    artifacts,
1715                    stem: &if n == 0 {
1716                        "advise-synthesis".to_owned()
1717                    } else {
1718                        format!("advise-synthesis-{}", spec.id)
1719                    },
1720                    run: run_id,
1721                    node: "advise",
1722                    cache_dir: None,
1723                    attachments: &[],
1724                    writable: &[],
1725                },
1726            )
1727            .await;
1728            if outcome.is_ok() {
1729                self.state.seats.insert(seat.key.clone(), seat);
1730            }
1731            let advance = agent::chain_advances(&outcome);
1732            last = Some(outcome);
1733            if !advance {
1734                break;
1735            }
1736        }
1737        // Exhausted: the last attempt's result is what a single failed seat
1738        // would have produced.
1739        let out = last.expect("a chain holds at least one agent")?;
1740        if !out.usable() {
1741            return Ok(None);
1742        }
1743        let text =
1744            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1745        Ok((!text.trim().is_empty()).then_some(text))
1746    }
1747
1748    // ----------------------------------------------------------- implement
1749
1750    async fn implement(&mut self) -> Result<()> {
1751        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1752        // agent files with `magi task add` name the run that paid for it. The
1753        // prompt overlay is cloned alongside it because the waves borrow it
1754        // while `self` is mutably borrowed by the node's own bookkeeping.
1755        let run_id = self.state.id.clone();
1756        let prompts = self.state.config.prompts.clone();
1757        let todo: Vec<usize> = self
1758            .state
1759            .candidates
1760            .iter()
1761            .enumerate()
1762            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1763            .map(|(i, _)| i)
1764            .collect();
1765        if todo.is_empty() {
1766            return self.after_implement();
1767        }
1768        self.state.status = RunStatus::Implementing;
1769
1770        let language = self.state.config.graph.language.clone();
1771        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1772        let sessions = self.state.config.graph.sessions;
1773        let artifacts = agent::artifacts_dir(&self.state.dir());
1774        // The design-deliberation stage's blended brief, when `advise` found
1775        // one — carried into every implementer's prompt the same way
1776        // regardless of which candidate it is.
1777        let brief = self
1778            .state
1779            .advice
1780            .as_ref()
1781            .and_then(|a| a.synthesis.as_deref())
1782            .map(str::to_owned);
1783        let attachments = self.state.attachments.clone();
1784
1785        let mut jobs = Vec::new();
1786        for &i in &todo {
1787            let (index, label, worktree) = {
1788                let c = &self.state.candidates[i];
1789                (c.index, c.label, c.worktree.clone())
1790            };
1791            let spec = self.roles.implementers[index].clone();
1792            let seat_key = format!("impl-{label}");
1793            let seat = self.seat(&seat_key, &spec.id);
1794            let instruction = seeded_instruction(&self.state);
1795            jobs.push(SeatJob {
1796                spec,
1797                seat,
1798                prompt: prompt::implement(
1799                    &instruction,
1800                    &worktree.to_string_lossy(),
1801                    &language,
1802                    brief.as_deref(),
1803                    &attachments,
1804                ),
1805                cwd: worktree,
1806                timeout,
1807                allow_write: true,
1808                sessions,
1809                artifacts: artifacts.clone(),
1810                stem: format!("impl-{label}"),
1811                handover: None,
1812            });
1813        }
1814
1815        self.state.event(
1816            "implement",
1817            format!("{} candidates in parallel", jobs.len()),
1818        );
1819        // Kept so a seat whose CLI hung up can be asked again from the same
1820        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1821        // can update a seat's own entry once a fallback agent takes it over —
1822        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1823        // whichever agent actually answered, not the one that quota'd out.
1824        let mut sent = jobs.clone();
1825        let cache = self.state.config.cache_dir();
1826        let ctx = WaveCtx {
1827            carry_seats: false,
1828            run: &run_id,
1829            node: "implement",
1830            prompts: &prompts,
1831            cache: cache.as_deref(),
1832            round: None,
1833        };
1834        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1835        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1836            .await;
1837        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1838            .await;
1839        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1840            .await;
1841
1842        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1843            let seat_key = seat.key.clone();
1844            // A quota fallback (`resume_seat_handovers`) may have handed this
1845            // seat to a different agent than the one `prep` recorded on the
1846            // candidate; the stats tables and any later fixer-defaults-to-
1847            // winner's-author lookup must credit whoever actually answered —
1848            // unless every fallback also quota'd out, in which case nobody
1849            // actually answered and crediting the last agent tried would
1850            // erase every earlier agent's own quota loss from the stats
1851            // tables instead of just this one seat's.
1852            let agent = seat.agent.clone();
1853            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1854            self.state.seats.insert(seat.key.clone(), seat);
1855            let label = self.state.candidates[i].label;
1856            let worktree = self.state.candidates[i].worktree.clone();
1857            let base = self.state.base_commit.clone();
1858
1859            let (summary, duration, failed, verified_claim) = match out {
1860                AgentOutcome::Ok(o) => {
1861                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1862                    let failed = (!o.usable()).then(|| {
1863                        if o.timed_out {
1864                            "agent timed out".to_owned()
1865                        } else {
1866                            format!("agent exited with {:?}", o.exit_code)
1867                        }
1868                    });
1869                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1870                    (text, o.duration_ms, failed, verified_claim)
1871                }
1872                // Left un-resumed by `resume_undelivered` (a dirty tree
1873                // already rescues the work, or there was no session left to
1874                // resume into) — reported like the ordinary failure it is,
1875                // never as if `o.text` (the CLI's raw error JSON) were an
1876                // answer.
1877                AgentOutcome::Dropped(o) => {
1878                    let why = o
1879                        .dropped
1880                        .as_ref()
1881                        .map(|d| d.why.as_str())
1882                        .unwrap_or("the CLI ended the stream without delivering its answer");
1883                    (
1884                        String::new(),
1885                        o.duration_ms,
1886                        Some(format!("the CLI dropped the stream ({why})")),
1887                        None,
1888                    )
1889                }
1890                AgentOutcome::Quota(o) => {
1891                    self.state.quota.push(QuotaLoss {
1892                        seat: seat_key,
1893                        node: "implement".to_owned(),
1894                        at: Timestamp::now(),
1895                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1896                    });
1897                    (
1898                        String::new(),
1899                        o.duration_ms,
1900                        Some("rate limited (quota); produced no change".to_owned()),
1901                        None,
1902                    )
1903                }
1904                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1905            };
1906
1907            // Rescue anything the agent edited but never committed: an
1908            // uncommitted candidate would silently be an empty one.
1909            let rescued = match git::rescue_commit(
1910                &worktree,
1911                &format!("magi: candidate {label} (uncommitted work)"),
1912            )
1913            .await
1914            {
1915                Ok(r) => {
1916                    self.state.note_withheld("implement", &r.withheld);
1917                    r.committed
1918                }
1919                Err(_) => false,
1920            };
1921            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1922                .await
1923                .unwrap_or(0);
1924            let patch = git::diff(&worktree, &base, "HEAD")
1925                .await
1926                .unwrap_or_default();
1927            let stat = git::diff_stat(&worktree, &base, "HEAD")
1928                .await
1929                .unwrap_or_default();
1930            let files = git::changed_files(&worktree, &base, "HEAD")
1931                .await
1932                .map(|f| f.len())
1933                .unwrap_or(0);
1934            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1935
1936            let c = &mut self.state.candidates[i];
1937            if !exhausted_the_fallback_chain {
1938                c.agent = agent;
1939            }
1940            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1941            c.stat = stat;
1942            c.files = files;
1943            c.commits = commits;
1944            c.duration_ms = duration;
1945            c.empty = commits == 0 || patch.trim().is_empty();
1946            // An agent that failed but still produced a committed change stays
1947            // in the running: the patch is what gets judged, not the exit code.
1948            c.failed = match failed {
1949                Some(_) if c.empty => failed,
1950                _ => None,
1951            };
1952            // Only an empty candidate can be a verified no-op: a claim next
1953            // to a real patch is not what the marker is for, and `c.failed`
1954            // being `Some` here already implies `verified_claim` was never
1955            // set (see the guard above the match that produced it).
1956            c.verified_noop = if c.empty { verified_claim } else { None };
1957            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1958                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1959                (None, true, Some(_), _) => {
1960                    format!("candidate {label}: no change produced (agent-verified no-op)")
1961                }
1962                (None, true, None, _) => format!("candidate {label}: no change produced"),
1963                (None, false, _, true) => {
1964                    format!(
1965                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1966                    )
1967                }
1968                (None, false, _, false) => {
1969                    format!("candidate {label}: {files} files, {commits} commits")
1970                }
1971            };
1972            self.state.event("implement", note);
1973            self.state.save()?;
1974        }
1975
1976        self.after_implement()
1977    }
1978
1979    /// Ask again, once, for work a CLI did and then failed to hand over.
1980    ///
1981    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1982    /// status with an empty response and a usage report showing output tokens,
1983    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1984    /// seven minutes and 14,267 output tokens that arrived as an empty
1985    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1986    ///
1987    /// Two conditions, and both matter:
1988    ///
1989    /// - **Only when the tree is untouched.** Often the agent has already
1990    ///   written its files and only the closing message was lost; the rescue
1991    ///   commit below picks that up and there is nothing to ask for. Re-asking
1992    ///   then would pay for a second implementation of work already on disk.
1993    /// - **Once.** A CLI that drops one stream can drop the next, and this
1994    ///   node is the most expensive in the graph.
1995    ///
1996    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1997    /// dropped reply still carried its `conversation_id`, so the seat is asked
1998    /// to finish what it was doing rather than sent the whole task again. It
1999    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
2000    /// node's - for the same reason a re-ranked judge does: restating finished
2001    /// work is not the work.
2002    ///
2003    /// Unlike a quota this is worth retrying at all: a rate limit fails the
2004    /// same way until it resets, while an abandoned conversation is still
2005    /// there to be picked up.
2006    async fn resume_undelivered(
2007        &mut self,
2008        results: &mut [(usize, SeatState, AgentOutcome)],
2009        sent: &[SeatJob],
2010        prompts: &Prompts,
2011        run_id: &str,
2012    ) {
2013        for (wi, seat, out) in results.iter_mut() {
2014            let Some(dropped) = (match &*out {
2015                AgentOutcome::Dropped(o) => o.dropped.clone(),
2016                _ => None,
2017            }) else {
2018                continue;
2019            };
2020            let Some(job) = sent.get(*wi) else { continue };
2021            // Already on disk? Then only the closing message was lost.
2022            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
2023                self.state.event(
2024                    "implement",
2025                    format!(
2026                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
2027                         work is in the tree",
2028                        seat.key, dropped.output_tokens, dropped.why
2029                    ),
2030                );
2031                continue;
2032            }
2033            // The re-ask only makes sense as a resume: `resume_after_drop`
2034            // says nothing about the task, trusting the seat to still hold it.
2035            // Without a session to resume — sessions disabled, or this CLI's
2036            // drop shape happened not to carry a session id — that prompt
2037            // would open a brand-new conversation with no context at all,
2038            // which is worse than leaving this as the ordinary failure it
2039            // already is.
2040            if !has_context(&job.spec, seat, job.sessions) {
2041                self.state.event(
2042                    "implement",
2043                    format!(
2044                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2045                         is no session left to resume",
2046                        seat.key, dropped.output_tokens, dropped.why
2047                    ),
2048                );
2049                continue;
2050            }
2051            self.state.event(
2052                "implement",
2053                format!(
2054                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2055                     conversation",
2056                    seat.key, dropped.output_tokens, dropped.why
2057                ),
2058            );
2059            let mut retry = job.clone();
2060            retry.seat = seat.clone();
2061            retry.prompt = prompt::resume_after_drop(&dropped.why);
2062            retry.timeout = retry_budget(job.timeout, true);
2063            retry.stem = format!("{}-resume", job.stem);
2064            let cache = self.state.config.cache_dir();
2065            let ctx = WaveCtx {
2066                carry_seats: false,
2067                run: run_id,
2068                node: "implement",
2069                prompts,
2070                cache: cache.as_deref(),
2071                round: None,
2072            };
2073            let (resumed_seat, resumed) =
2074                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2075            *seat = resumed_seat;
2076            *out = resumed;
2077        }
2078    }
2079
2080    /// Fall an implement seat through to the next untried agent in the
2081    /// implementer roster when it lost to quota — or, since the handover was
2082    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2083    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2084    /// quota path itself being unchanged — instead of leaving the
2085    /// seat's loss final the moment one agent's account runs dry.
2086    ///
2087    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2088    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2089    /// truncates to the single slot rotation picked, so a solo task whose one
2090    /// implementer hits quota mid-run used to have nothing else to try. This
2091    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2092    /// unrotated roster — which is the only place the *other* candidates in
2093    /// the machine's roster still exist once `implementers` has been cut down
2094    /// to size.
2095    ///
2096    /// Walks forward from just past the seat's own original position in the
2097    /// roster, never wrapping back to the front: a later candidate slot (say
2098    /// `beta`, the roster's second entry) must fall through to the *next*
2099    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2100    /// almost certainly a different candidate's own agent already — and once
2101    /// the roster's tail is exhausted there is nothing left to fall through
2102    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2103    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2104    /// let this retry that id forever. The loop keeps falling through until
2105    /// an attempt lands something other than `Quota` or the roster's tail
2106    /// runs out of untried ids, at which point the seat is left exactly as
2107    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2108    /// `QuotaLoss` recorded, the candidate failed/empty.
2109    ///
2110    /// `sent` is taken mutably and updated with the fallback agent's spec:
2111    /// `resume_unconfirmed_commands`, which runs after this and also reads
2112    /// `sent`, must see whichever agent actually ended up answering the seat
2113    /// — reading the stale, original spec there would check session
2114    /// eligibility against the wrong CLI and could hand a fallback agent's
2115    /// session id to the agent that just lost the seat to quota.
2116    ///
2117    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2118    /// — `self.seat` only reuses state when the agent id is unchanged, so
2119    /// handing it a different id already gets this for free. Reusing the old
2120    /// seat would resume a different CLI's session as if it were a
2121    /// continuation of this one.
2122    ///
2123    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2124    /// a quota loss cuts an agent off mid-turn, so anything already in the
2125    /// tree is unfinished work, not a completed candidate a re-ask would pay
2126    /// for twice. A dirty tree is rescued into a commit first (the same
2127    /// neutral-identity rescue `implement`'s own outcome loop gives every
2128    /// candidate) so the next agent starts clean.
2129    ///
2130    /// The new agent gets the implementer's full prompt and full
2131    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2132    /// it has no session and no context, and is implementing the task from
2133    /// nothing, unlike a resumed drop which is only restating work already
2134    /// done.
2135    ///
2136    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2137    /// `implement` event, never into `self.state.quota` — that is what
2138    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2139    /// go unspent, and a seat that ultimately recovered on its second or
2140    /// third agent is not the stalled panel that check exists to catch. Only
2141    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2142    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2143    /// outcome loop already has — this helper never pushes to it itself.
2144    async fn resume_seat_handovers(
2145        &mut self,
2146        results: &mut [(usize, SeatState, AgentOutcome)],
2147        sent: &mut [SeatJob],
2148        prompts: &Prompts,
2149        run_id: &str,
2150    ) {
2151        let instruction = seeded_instruction(&self.state);
2152        let language = self.state.config.graph.language.clone();
2153        let brief = self
2154            .state
2155            .advice
2156            .as_ref()
2157            .and_then(|a| a.synthesis.as_deref())
2158            .map(str::to_owned);
2159        let attachments = self.state.attachments.clone();
2160        for (wi, seat, out) in results.iter_mut() {
2161            // Who holds the other candidate seats of this wave right now
2162            // (earlier handovers already written back to `sent`).
2163            let others: BTreeSet<String> = sent
2164                .iter()
2165                .enumerate()
2166                .filter(|(j, _)| j != wi)
2167                .map(|(_, j)| j.spec.id.clone())
2168                .collect();
2169            let Some(job) = sent.get_mut(*wi) else {
2170                continue;
2171            };
2172            // Where the seat's own original agent sits in the roster — the
2173            // fallback walk starts just past here, never at the front, so a
2174            // later candidate slot's quota loss does not fall back onto an
2175            // earlier slot's own agent.
2176            let start = self
2177                .roles
2178                .implementer_roster
2179                .iter()
2180                .position(|s| s.id == job.spec.id)
2181                .unwrap_or(0);
2182            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2183            let mut fallback_attempt = 0usize;
2184            let mut prev: Option<FailClass> = None;
2185            while let Some(cur) = FailClass::of(&*out) {
2186                if !should_hand_over(prev.as_ref(), &cur) {
2187                    break;
2188                }
2189                let Some(next) =
2190                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2191                        .cloned()
2192                else {
2193                    break;
2194                };
2195                tried.insert(next.id.clone());
2196                fallback_attempt += 1;
2197
2198                if let Ok(r) = git::rescue_commit(
2199                    &job.cwd,
2200                    &format!(
2201                        "magi: candidate {} (uncommitted work before {} fallback)",
2202                        seat.key,
2203                        if cur == FailClass::Quota {
2204                            "quota"
2205                        } else {
2206                            "handover"
2207                        }
2208                    ),
2209                )
2210                .await
2211                {
2212                    self.state.note_withheld("implement", &r.withheld);
2213                }
2214
2215                record_handover(
2216                    &mut self.state,
2217                    "implement",
2218                    &seat.key,
2219                    &seat.agent,
2220                    &next.id,
2221                    &cur,
2222                    &fail_reason(&*out),
2223                );
2224                prev = Some(cur.clone());
2225
2226                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2227                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2228                // Kept in sync on `sent` itself, not just the local retry: a
2229                // later helper (`resume_unconfirmed_commands`) reads `sent`
2230                // after this one returns and must see whichever agent is now
2231                // occupying the seat, not the one that just quota'd out —
2232                // otherwise it would judge session/continuation eligibility
2233                // by the wrong CLI and could resend a fallback's session id
2234                // to the agent that lost it the seat in the first place.
2235                job.spec = next.clone();
2236                let mut retry = job.clone();
2237                retry.seat = new_seat;
2238                retry.prompt = prompt::implement(
2239                    &instruction,
2240                    &job.cwd.to_string_lossy(),
2241                    &language,
2242                    brief.as_deref(),
2243                    &attachments,
2244                );
2245                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2246                let cache = self.state.config.cache_dir();
2247                let ctx = WaveCtx {
2248                    carry_seats: false,
2249                    run: run_id,
2250                    node: "implement",
2251                    prompts,
2252                    cache: cache.as_deref(),
2253                    round: None,
2254                };
2255                let (fallback_seat, fallback_out) = run_one(
2256                    retry,
2257                    Arc::clone(&self.sem),
2258                    &ctx,
2259                    &mut self.state,
2260                    fallback_attempt,
2261                )
2262                .await;
2263                *seat = fallback_seat;
2264                *out = fallback_out;
2265            }
2266        }
2267    }
2268
2269    /// Ask an implement seat's own CLI to confirm what it started, once, when
2270    /// its reply reported a command whose completion status it never
2271    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2272    /// that does and does not mean.
2273    ///
2274    /// The completion contract this task asks for, extended to `implement`
2275    /// with the same signal `continue_fix_report` reads for the fixer,
2276    /// rather than a keyword search over the reply or a hard requirement on
2277    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2278    /// a candidate's CLI turn ended cleanly while a test run it had started
2279    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2280    /// named in it at all is untouched by this: `commands` is empty, so
2281    /// there is nothing to be unconfirmed.
2282    ///
2283    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2284    /// this is not about recovering edits that might already be on disk, it
2285    /// is about a result the seat itself never vouched for, which resuming
2286    /// asks for regardless of what the tree already holds. Bounded to one
2287    /// attempt for the same reason `resume_undelivered` is — this is the
2288    /// most expensive node in the graph — and a seat that still cannot
2289    /// confirm on that attempt is left as whatever its (possibly still
2290    /// unconfirmed) reply says; this does not invent a new "failed" reason
2291    /// for a candidate that otherwise produced a real, committed change.
2292    async fn resume_unconfirmed_commands(
2293        &mut self,
2294        results: &mut [(usize, SeatState, AgentOutcome)],
2295        sent: &[SeatJob],
2296        prompts: &Prompts,
2297        run_id: &str,
2298    ) {
2299        for (wi, seat, out) in results.iter_mut() {
2300            let AgentOutcome::Ok(o) = &*out else {
2301                continue;
2302            };
2303            if !has_unconfirmed_command(&o.commands) {
2304                continue;
2305            }
2306            let Some(job) = sent.get(*wi) else { continue };
2307            if !has_context(&job.spec, seat, job.sessions) {
2308                self.state.event(
2309                    "implement",
2310                    format!(
2311                        "{}: the reply named a command whose own CLI never confirmed the exit \
2312                         status of, but there is no session left to resume",
2313                        seat.key
2314                    ),
2315                );
2316                continue;
2317            }
2318            self.state.event(
2319                "implement",
2320                format!(
2321                    "{}: the reply named a command whose own CLI never confirmed the exit \
2322                     status of; resuming the conversation",
2323                    seat.key
2324                ),
2325            );
2326            let mut retry = job.clone();
2327            retry.seat = seat.clone();
2328            retry.prompt = prompt::resume_incomplete(
2329                "a command in your last reply had no confirmed exit status",
2330            );
2331            retry.timeout = retry_budget(job.timeout, true);
2332            retry.stem = format!("{}-confirm", job.stem);
2333            let cache = self.state.config.cache_dir();
2334            let ctx = WaveCtx {
2335                carry_seats: false,
2336                run: run_id,
2337                node: "implement",
2338                prompts,
2339                cache: cache.as_deref(),
2340                round: None,
2341            };
2342            let (resumed_seat, resumed) =
2343                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2344            *seat = resumed_seat;
2345            *out = resumed;
2346        }
2347    }
2348
2349    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2350    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2351    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2352    /// that motivated this.
2353    ///
2354    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2355    /// own nudge loop already covers for judge/review/vote seats, and not a
2356    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2357    /// implement seats: here the CLI turn genuinely finished while the node's
2358    /// own work — the fixer's account of what it did — had not. Gated purely
2359    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2360    /// reply, never on any wording in it, so a fixer whose valid, first-try
2361    /// `FixReport` happens to mention having waited on a background test is
2362    /// never resumed — the `Ok(report)` branch at the call site returns
2363    /// before this is ever invoked.
2364    ///
2365    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2366    /// attempt ([`retry_budget`]), nothing attempted once the session is
2367    /// gone, and a quota hit ends the loop immediately rather than retrying a
2368    /// rate limit that fails the same way again.
2369    async fn continue_fix_report(
2370        &mut self,
2371        mut seat: SeatState,
2372        parse_err: String,
2373        job: &SeatJob,
2374        prompts: &Prompts,
2375        run_id: &str,
2376        round: usize,
2377    ) -> (
2378        SeatState,
2379        Option<FixReport>,
2380        Option<String>,
2381        ContinuationRecord,
2382    ) {
2383        let mut last_err = parse_err;
2384        let mut cumulative_wait_ms = 0u64;
2385        let mut attempts = 0usize;
2386        loop {
2387            if !has_context(&job.spec, &seat, job.sessions) {
2388                self.state.event(
2389                    "fix",
2390                    format!(
2391                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2392                         session left to resume into"
2393                    ),
2394                );
2395                let outcome = if attempts == 0 {
2396                    ContinuationOutcome::NoSession
2397                } else {
2398                    ContinuationOutcome::Exhausted
2399                };
2400                return (
2401                    seat,
2402                    None,
2403                    Some(format!("unparsable fix report: {last_err}")),
2404                    ContinuationRecord {
2405                        attempts,
2406                        cumulative_wait_ms,
2407                        outcome,
2408                    },
2409                );
2410            }
2411            if attempts >= MAX_FIX_CONTINUATIONS {
2412                self.state.event(
2413                    "fix",
2414                    format!(
2415                        "round {round}: fixer's reply still had no adoption report after \
2416                         {attempts} continuation(s) ({last_err}); giving up"
2417                    ),
2418                );
2419                return (
2420                    seat,
2421                    None,
2422                    Some(format!(
2423                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2424                    )),
2425                    ContinuationRecord {
2426                        attempts,
2427                        cumulative_wait_ms,
2428                        outcome: ContinuationOutcome::Exhausted,
2429                    },
2430                );
2431            }
2432            attempts += 1;
2433            self.state.event(
2434                "fix",
2435                format!(
2436                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2437                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2438                ),
2439            );
2440            let mut retry = job.clone();
2441            retry.seat = seat.clone();
2442            retry.prompt = prompt::resume_incomplete(&last_err);
2443            retry.timeout = retry_budget(job.timeout, true);
2444            retry.stem = format!("{}-continue{attempts}", job.stem);
2445            let cache = self.state.config.cache_dir();
2446            let ctx = WaveCtx {
2447                carry_seats: false,
2448                run: run_id,
2449                node: "fix",
2450                prompts,
2451                cache: cache.as_deref(),
2452                round: Some(round),
2453            };
2454            let (resumed_seat, resumed_out) = run_one(
2455                retry,
2456                Arc::clone(&self.sem),
2457                &ctx,
2458                &mut self.state,
2459                attempts,
2460            )
2461            .await;
2462            seat = resumed_seat;
2463            match resumed_out {
2464                AgentOutcome::Ok(o) => {
2465                    cumulative_wait_ms += o.duration_ms;
2466                    match verdict::extract_json::<FixReport>(&o.text) {
2467                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2468                            self.state.event(
2469                                "fix",
2470                                format!(
2471                                    "round {round}: fixer's adoption report recovered after \
2472                                     {attempts} continuation(s)"
2473                                ),
2474                            );
2475                            return (
2476                                seat,
2477                                Some(report),
2478                                None,
2479                                ContinuationRecord {
2480                                    attempts,
2481                                    cumulative_wait_ms,
2482                                    outcome: ContinuationOutcome::Resumed,
2483                                },
2484                            );
2485                        }
2486                        // The report parsed, but this same reply's own
2487                        // CommandEvidence — the identical record `state.jobs`
2488                        // renders — names a command whose CLI never
2489                        // confirmed an exit status. Read together, that is
2490                        // not a resolved answer: keep nudging rather than
2491                        // accept a report standing next to a command the
2492                        // seat's own CLI cannot vouch for.
2493                        Ok(_) => {
2494                            last_err = "the reply parsed, but it reported a command whose own CLI \
2495                                 never confirmed an exit status"
2496                                .to_owned();
2497                        }
2498                        Err(e) => last_err = e.to_string(),
2499                    }
2500                }
2501                AgentOutcome::Quota(o) => {
2502                    cumulative_wait_ms += o.duration_ms;
2503                    self.state.quota.push(QuotaLoss {
2504                        seat: seat.key.clone(),
2505                        node: "fix".to_owned(),
2506                        at: Timestamp::now(),
2507                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2508                    });
2509                    self.state.event(
2510                        "fix",
2511                        format!(
2512                            "round {round}: continuation rate limited (quota); not retrying now"
2513                        ),
2514                    );
2515                    return (
2516                        seat,
2517                        None,
2518                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2519                        ContinuationRecord {
2520                            attempts,
2521                            cumulative_wait_ms,
2522                            outcome: ContinuationOutcome::QuotaLost,
2523                        },
2524                    );
2525                }
2526                AgentOutcome::Dropped(o) => {
2527                    cumulative_wait_ms += o.duration_ms;
2528                    let why = o
2529                        .dropped
2530                        .as_ref()
2531                        .map(|d| d.why.as_str())
2532                        .unwrap_or("the CLI ended the stream without delivering its answer");
2533                    last_err = format!("the CLI dropped the stream ({why})");
2534                }
2535                AgentOutcome::Failed(e) => last_err = e,
2536            }
2537        }
2538    }
2539
2540    fn after_implement(&mut self) -> Result<()> {
2541        // Scan every candidate patch once the set is complete.
2542        if self.state.leaks.is_empty() {
2543            let cfg = self.state.config.blind.clone();
2544            let mut leaks = Vec::new();
2545            for c in &self.state.candidates {
2546                let Some(patch) =
2547                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2548                else {
2549                    continue;
2550                };
2551                leaks.extend(blind::scan(
2552                    &format!("candidate {} patch", c.label),
2553                    &patch,
2554                    &cfg.vendor_tokens,
2555                ));
2556            }
2557            if !leaks.is_empty() {
2558                let summary = leaks
2559                    .iter()
2560                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2561                    .collect::<Vec<_>>()
2562                    .join(", ");
2563                match cfg.on_leak {
2564                    LeakPolicy::Fail => {
2565                        self.state.status = RunStatus::Failed;
2566                        self.state
2567                            .event("blind", format!("vendor text in a patch: {summary}"));
2568                        self.state.leaks = leaks;
2569                        self.state.save()?;
2570                        self.settle_questions();
2571                        bail!(
2572                            "blind.on_leak = \"fail\" and vendor text reached a \
2573                             judged patch: {summary}"
2574                        );
2575                    }
2576                    LeakPolicy::Redact => self.state.event(
2577                        "blind",
2578                        format!("redacting vendor text for judging: {summary}"),
2579                    ),
2580                    LeakPolicy::Warn => self.state.event(
2581                        "blind",
2582                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2583                    ),
2584                }
2585                self.state.leaks = leaks;
2586            }
2587        }
2588
2589        if self.state.viable().is_empty() {
2590            if self.state.all_candidates_verified_noop() {
2591                // Every candidate agreed, with evidence the adoption guard
2592                // accepted, that nothing belongs in this worktree. That is
2593                // not the same fact as a candidate that simply failed to
2594                // write anything, and settling it as an ordinary `Failed`
2595                // (see `SCHEMA`'s doc for schema 10) is what let two of
2596                // task 391f's attempts burn a retry each re-discovering the
2597                // same already-landed fix. Terminal either way, so `judge`
2598                // must never run over an empty candidate set — unlike the
2599                // `Failed` branch below this returns `Ok`, not an error:
2600                // nothing here failed.
2601                self.state.status = RunStatus::VerifiedNoop;
2602                self.state.save()?;
2603                self.settle_questions();
2604                return Ok(());
2605            }
2606            self.state.status = RunStatus::Failed;
2607            self.state.save()?;
2608            self.settle_questions();
2609            bail!("no candidate produced a change; nothing to judge");
2610        }
2611        self.state.status = RunStatus::Judging;
2612        self.state.save()?;
2613        Ok(())
2614    }
2615
2616    // --------------------------------------------------------------- judge
2617
2618    async fn judge(&mut self) -> Result<()> {
2619        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2620        // agent files with `magi task add` name the run that paid for it. The
2621        // prompt overlay is cloned alongside it because the waves borrow it
2622        // while `self` is mutably borrowed by the node's own bookkeeping.
2623        let run_id = self.state.id.clone();
2624        let prompts = self.state.config.prompts.clone();
2625        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2626            return Ok(());
2627        }
2628        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2629        if viable.len() == 1 {
2630            // Recorded so this is a one-time event: `judgements` stays empty
2631            // either way, which without this flag is indistinguishable from
2632            // "not yet judged" on the next reentry — and status is left
2633            // untouched, so a later node's conclusion (e.g. `Blocked` after
2634            // the review budget ran out) survives a resume instead of being
2635            // clobbered back to `Judging` by this node running again.
2636            self.state.judge_skipped = true;
2637            self.state.event(
2638                "judge",
2639                format!(
2640                    "only candidate {} produced a change; judging skipped",
2641                    viable[0].label
2642                ),
2643            );
2644            self.state.save()?;
2645            return Ok(());
2646        }
2647        self.state.status = RunStatus::Judging;
2648
2649        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2650        let language = self.state.config.graph.language.clone();
2651        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2652        let sessions = self.state.config.graph.sessions;
2653        let artifacts = agent::artifacts_dir(&self.state.dir());
2654        let root = self.state.worktree_root();
2655        let base_short = short(&self.state.base_commit);
2656
2657        let mut jobs = Vec::new();
2658        let mut orders = Vec::new();
2659        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2660            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2661            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2662            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2663            let seat_key = format!("judge-{}", j + 1);
2664            let seat = self.seat(&seat_key, &spec.id);
2665            jobs.push(SeatJob {
2666                prompt: prompt::judge(
2667                    &self.state.instruction,
2668                    &views,
2669                    self.roles.judges.len(),
2670                    &base_short,
2671                    &language,
2672                ),
2673                spec,
2674                seat,
2675                cwd: root.join(format!("judge-{}", j + 1)),
2676                timeout,
2677                allow_write: false,
2678                sessions,
2679                artifacts: artifacts.clone(),
2680                stem: format!("judge-{}", j + 1),
2681                handover: None,
2682            });
2683        }
2684
2685        self.state.event(
2686            "judge",
2687            format!(
2688                "{} judges ranking {} candidates blind",
2689                jobs.len(),
2690                viable.len()
2691            ),
2692        );
2693        let labels_for_check = labels.clone();
2694        let mut quota_losses = Vec::new();
2695        let cache = self.state.config.cache_dir();
2696        let ctx = WaveCtx {
2697            carry_seats: false,
2698            run: &run_id,
2699            node: "judge",
2700            prompts: &prompts,
2701            cache: cache.as_deref(),
2702            round: None,
2703        };
2704        let results = ask_json_wave::<Ranking>(
2705            jobs,
2706            Arc::clone(&self.sem),
2707            self.state.config.graph.retries,
2708            &self.roles.judge_roster,
2709            &ctx,
2710            &mut quota_losses,
2711            &mut self.state,
2712            &move |r: &Ranking| r.validate(&labels_for_check),
2713        )
2714        .await;
2715        self.state.quota.extend(quota_losses);
2716
2717        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2718            let agent_id = seat.agent.clone();
2719            self.state.seats.insert(seat.key.clone(), seat);
2720            let mut record = Judgement {
2721                judge: j + 1,
2722                seat: format!("judge-{}", j + 1),
2723                agent: agent_id,
2724                ranking: Vec::new(),
2725                reasons: BTreeMap::new(),
2726                confidence: None,
2727                strengths: BTreeMap::new(),
2728                order: orders[j].clone(),
2729                failed: None,
2730                duration_ms: 0,
2731            };
2732            match res {
2733                Ok((ranking, out)) => {
2734                    record.ranking = ranking.normalized();
2735                    record.reasons = ranking.reasons;
2736                    record.strengths = ranking.strengths;
2737                    record.confidence = ranking.confidence;
2738                    record.duration_ms = out.duration_ms;
2739                    self.state.event(
2740                        "judge",
2741                        format!(
2742                            "judge {} ranked {}",
2743                            j + 1,
2744                            record.ranking.iter().collect::<String>()
2745                        ),
2746                    );
2747                }
2748                Err(e) => {
2749                    record.failed = Some(e.to_string());
2750                    self.state
2751                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2752                }
2753            }
2754            self.state.judgements.push(record);
2755            self.state.save()?;
2756        }
2757        Ok(())
2758    }
2759
2760    // ---------------------------------------------------------- deliberate
2761
2762    async fn deliberate(&mut self) -> Result<()> {
2763        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2764        // agent files with `magi task add` name the run that paid for it. The
2765        // prompt overlay is cloned alongside it because the waves borrow it
2766        // while `self` is mutably borrowed by the node's own bookkeeping.
2767        let run_id = self.state.id.clone();
2768        let prompts = self.state.config.prompts.clone();
2769        if !self.state.deliberation.is_empty() {
2770            return Ok(());
2771        }
2772        let tops: Vec<char> = self
2773            .state
2774            .judgements
2775            .iter()
2776            .filter_map(|j| j.ranking.first().copied())
2777            .collect();
2778        let rounds = self.state.config.graph.deliberate_rounds;
2779        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2780            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2781                self.state.event(
2782                    "deliberate",
2783                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2784                );
2785            }
2786            self.state.status = RunStatus::Voting;
2787            self.state.save()?;
2788            return Ok(());
2789        }
2790
2791        self.state.status = RunStatus::Deliberating;
2792        self.state.event(
2793            "deliberate",
2794            format!(
2795                "split: first choices were {} — opening {rounds} round(s)",
2796                tops.iter().collect::<String>()
2797            ),
2798        );
2799
2800        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2801        let language = self.state.config.graph.language.clone();
2802        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2803        let sessions = self.state.config.graph.sessions;
2804        let artifacts = agent::artifacts_dir(&self.state.dir());
2805        let root = self.state.worktree_root();
2806        let base_short = short(&self.state.base_commit);
2807
2808        // Judges argue in sequence so that a turn can answer the one before it;
2809        // that is the difference between deliberation and three parallel
2810        // monologues.
2811        for round in 1..=rounds {
2812            let mut turns: Vec<DeliberationTurn> = Vec::new();
2813            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2814                if self.state.judgements[j].failed.is_some() {
2815                    continue;
2816                }
2817                let seat_key = format!("judge-{}", j + 1);
2818                let spec = self.occupant(&seat_key, spec);
2819                let mut seat = self.seat(&seat_key, &spec.id);
2820                let transcript = self.transcript(&turns, j);
2821                let build = |context: Option<&str>| {
2822                    prompt::deliberate(
2823                        &self.state.instruction,
2824                        context,
2825                        &transcript,
2826                        round,
2827                        rounds,
2828                        &language,
2829                    )
2830                };
2831                let block = self.candidate_block(&viable, &base_short);
2832                let full = build(Some(&block));
2833                let text = if has_context(&spec, &seat, sessions) {
2834                    build(None)
2835                } else {
2836                    full.clone()
2837                };
2838                let job = SeatJob {
2839                    spec,
2840                    seat: seat.clone(),
2841                    prompt: text,
2842                    cwd: root.join(format!("judge-{}", j + 1)),
2843                    timeout,
2844                    allow_write: false,
2845                    sessions,
2846                    artifacts: artifacts.clone(),
2847                    stem: format!("delib-{round}-judge-{}", j + 1),
2848                    handover: Some(full),
2849                };
2850                let cache = self.state.config.cache_dir();
2851                let ctx = WaveCtx {
2852                    carry_seats: false,
2853                    run: &run_id,
2854                    node: "deliberate",
2855                    prompts: &prompts,
2856                    cache: cache.as_deref(),
2857                    round: None,
2858                };
2859                // A turn is never nudged (`retries` 0): a failed seat is
2860                // handed to the next roster agent, which gets the full
2861                // context. An empty answer is a turn, not a failure.
2862                let mut losses = Vec::new();
2863                let mut results = ask_wave_with::<String>(
2864                    vec![job],
2865                    Arc::clone(&self.sem),
2866                    0,
2867                    &self.roles.judge_roster,
2868                    &ctx,
2869                    &mut losses,
2870                    &mut self.state,
2871                    &|text: &str| {
2872                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2873                    },
2874                )
2875                .await;
2876                self.state.quota.extend(losses);
2877                let (updated, res, _) = results.pop().expect("one job in, one result out");
2878                seat = updated;
2879                let agent_id = seat.agent.clone();
2880                self.state.seats.insert(seat.key.clone(), seat);
2881                let body = match res {
2882                    Ok((body, _)) => body,
2883                    // Skip the seat; a CLI's raw error JSON is never read as
2884                    // this judge's position.
2885                    Err(e) => {
2886                        self.state
2887                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2888                        continue;
2889                    }
2890                };
2891                let tentative = verdict::extract_json::<Position>(&body)
2892                    .ok()
2893                    .and_then(|p| p.tentative)
2894                    .and_then(|s| s.trim().chars().next())
2895                    .map(|c| c.to_ascii_uppercase());
2896                self.state.event(
2897                    "deliberate",
2898                    format!(
2899                        "round {round}: judge {} now favours {}",
2900                        j + 1,
2901                        tentative.map_or("—".to_owned(), |c| c.to_string())
2902                    ),
2903                );
2904                turns.push(DeliberationTurn {
2905                    judge: j + 1,
2906                    agent: agent_id,
2907                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2908                    tentative,
2909                });
2910            }
2911            self.state
2912                .deliberation
2913                .push(DeliberationRound { round, turns });
2914            self.state.save()?;
2915        }
2916
2917        self.state.status = RunStatus::Voting;
2918        self.state.save()?;
2919        Ok(())
2920    }
2921
2922    // ---------------------------------------------------------------- vote
2923
2924    async fn vote(&mut self) -> Result<()> {
2925        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2926        // agent files with `magi task add` name the run that paid for it. The
2927        // prompt overlay is cloned alongside it because the waves borrow it
2928        // while `self` is mutably borrowed by the node's own bookkeeping.
2929        let run_id = self.state.id.clone();
2930        let prompts = self.state.config.prompts.clone();
2931        if !self.state.votes.is_empty() {
2932            return Ok(());
2933        }
2934        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2935        if viable.len() == 1 {
2936            return Ok(());
2937        }
2938        self.state.status = RunStatus::Voting;
2939
2940        let language = self.state.config.graph.language.clone();
2941        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2942        let sessions = self.state.config.graph.sessions;
2943        let artifacts = agent::artifacts_dir(&self.state.dir());
2944        let root = self.state.worktree_root();
2945        let base_short = short(&self.state.base_commit);
2946        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2947
2948        let mut jobs = Vec::new();
2949        let mut seats_at = Vec::new();
2950        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2951            if self
2952                .state
2953                .judgements
2954                .get(j)
2955                .is_some_and(|r| r.failed.is_some())
2956            {
2957                continue;
2958            }
2959            let seat_key = format!("judge-{}", j + 1);
2960            let spec = self.occupant(&seat_key, spec);
2961            let seat = self.seat(&seat_key, &spec.id);
2962            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2963            let text = if has_context(&spec, &seat, sessions) {
2964                prompt::final_vote(&viable, &language)
2965            } else {
2966                full.clone()
2967            };
2968            jobs.push(SeatJob {
2969                spec,
2970                seat,
2971                prompt: text,
2972                cwd: root.join(format!("judge-{}", j + 1)),
2973                timeout,
2974                allow_write: false,
2975                sessions,
2976                artifacts: artifacts.clone(),
2977                stem: format!("vote-judge-{}", j + 1),
2978                handover: Some(full),
2979            });
2980            seats_at.push(j);
2981        }
2982
2983        self.state.event(
2984            "vote",
2985            format!(
2986                "collecting {} final votes one by one, privately",
2987                jobs.len()
2988            ),
2989        );
2990        let allowed = viable.clone();
2991        let mut quota_losses = Vec::new();
2992        let cache = self.state.config.cache_dir();
2993        let ctx = WaveCtx {
2994            carry_seats: false,
2995            run: &run_id,
2996            node: "vote",
2997            prompts: &prompts,
2998            cache: cache.as_deref(),
2999            round: None,
3000        };
3001        let results = ask_json_wave::<FinalVote>(
3002            jobs,
3003            Arc::clone(&self.sem),
3004            self.state.config.graph.retries,
3005            &self.roles.judge_roster,
3006            &ctx,
3007            &mut quota_losses,
3008            &mut self.state,
3009            &move |v: &FinalVote| match v.label() {
3010                Some(c) if allowed.contains(&c) => Ok(()),
3011                other => bail!("vote {other:?} is not one of {allowed:?}"),
3012            },
3013        )
3014        .await;
3015        self.state.quota.extend(quota_losses);
3016
3017        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
3018            let agent_id = seat.agent.clone();
3019            self.state.seats.insert(seat.key.clone(), seat);
3020            let initial = self
3021                .state
3022                .judgements
3023                .get(j)
3024                .and_then(|r| r.ranking.first().copied());
3025            let mut record = VoteRecord {
3026                judge: j + 1,
3027                agent: agent_id,
3028                vote: None,
3029                reason: String::new(),
3030                changed: false,
3031            };
3032            match res {
3033                Ok((v, _)) => {
3034                    record.vote = v.label();
3035                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3036                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
3037                    self.state.event(
3038                        "vote",
3039                        format!(
3040                            "judge {} voted {}{}",
3041                            j + 1,
3042                            record.vote.unwrap_or('?'),
3043                            if record.changed { " (changed)" } else { "" }
3044                        ),
3045                    );
3046                }
3047                Err(e) => {
3048                    self.state
3049                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3050                }
3051            }
3052            self.state.votes.push(record);
3053            self.state.save()?;
3054        }
3055        Ok(())
3056    }
3057
3058    // --------------------------------------------------------------- tally
3059
3060    fn tally(&mut self) -> Result<()> {
3061        if self.state.tally.is_some() {
3062            return Ok(());
3063        }
3064        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3065        let tops: Vec<char> = self
3066            .state
3067            .judgements
3068            .iter()
3069            .filter_map(|j| j.ranking.first().copied())
3070            .collect();
3071        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3072
3073        // A judge whose private vote failed still counted once, in the initial
3074        // ranking; using it beats discarding a whole seat.
3075        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3076        let mut cast: Vec<char> = Vec::new();
3077        for (i, j) in self.state.judgements.iter().enumerate() {
3078            let vote = self
3079                .state
3080                .votes
3081                .iter()
3082                .find(|v| v.judge == i + 1)
3083                .and_then(|v| v.vote)
3084                .or_else(|| j.ranking.first().copied());
3085            if let Some(v) = vote {
3086                *first_choice.entry(v).or_insert(0) += 1;
3087                cast.push(v);
3088            }
3089        }
3090
3091        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3092        for j in &self.state.judgements {
3093            let n = j.ranking.len();
3094            for (pos, label) in j.ranking.iter().enumerate() {
3095                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3096            }
3097        }
3098
3099        let best = first_choice.values().copied().max().unwrap_or(0);
3100        let mut leaders: Vec<char> = first_choice
3101            .iter()
3102            .filter(|(_, v)| **v == best)
3103            .map(|(k, _)| *k)
3104            .collect();
3105        let mut tie_break = None;
3106        if leaders.len() > 1 {
3107            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3108            let borda_leaders: Vec<char> = leaders
3109                .iter()
3110                .copied()
3111                .filter(|l| borda[l] == top_borda)
3112                .collect();
3113            tie_break = Some(if borda_leaders.len() == 1 {
3114                format!(
3115                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3116                    leaders.len()
3117                )
3118            } else {
3119                format!(
3120                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3121                    leaders.len()
3122                )
3123            });
3124            leaders = borda_leaders;
3125            leaders.sort_unstable();
3126        }
3127        let winner = *leaders
3128            .first()
3129            .or(viable.first())
3130            .context("no candidate to declare a winner from")?;
3131
3132        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3133        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3134        let deliberated = !self.state.deliberation.is_empty();
3135
3136        // Whose verdict is this? A rate-limited seat is absent even if it
3137        // ranked before the limit hit, so presence is measured against the
3138        // recorded losses, not just "did a ranking ever appear".
3139        let quota_seats: std::collections::BTreeSet<&str> =
3140            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3141        let mut present = 0usize;
3142        for (i, j) in self.state.judgements.iter().enumerate() {
3143            if quota_seats.contains(j.seat.as_str()) {
3144                continue;
3145            }
3146            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3147            let voted = self
3148                .state
3149                .votes
3150                .iter()
3151                .any(|v| v.judge == i + 1 && v.vote.is_some());
3152            if ranked || voted {
3153                present += 1;
3154            }
3155        }
3156        // Strict majority of the configured panel. A bare majority is real
3157        // signal we can act on, while a minority verdict must never stand in
3158        // for a healthy one. A one-candidate run needs no panel at all, and
3159        // `judges` stays `0` rather than the roster size a panel that never
3160        // sat would otherwise be credited with.
3161        let needs_quorum = viable.len() > 1;
3162        let judges_total = if needs_quorum {
3163            self.roles.judges.len()
3164        } else {
3165            0
3166        };
3167        let quorum = if needs_quorum {
3168            judges_total / 2 + 1
3169        } else {
3170            0
3171        };
3172        let met_quorum = !needs_quorum || present >= quorum;
3173        let uncontested = (!needs_quorum).then(|| {
3174            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3175        });
3176
3177        self.state.event(
3178            "tally",
3179            match &uncontested {
3180                Some(reason) => format!("winner {winner} — {reason}"),
3181                None => format!(
3182                    "winner {winner} — votes {} | initial {} | {} changed | \
3183                     {present}/{judges_total} judges{}",
3184                    first_choice
3185                        .iter()
3186                        .map(|(k, v)| format!("{k}:{v}"))
3187                        .collect::<Vec<_>>()
3188                        .join(" "),
3189                    if unanimous_initial {
3190                        "unanimous"
3191                    } else {
3192                        "split"
3193                    },
3194                    changed_votes,
3195                    if met_quorum {
3196                        String::new()
3197                    } else {
3198                        format!(" — below quorum ({quorum} required)")
3199                    },
3200                ),
3201            },
3202        );
3203        if !met_quorum {
3204            self.state.event(
3205                "stall",
3206                format!(
3207                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3208                     the run stops here, resumable"
3209                ),
3210            );
3211        }
3212        self.state.tally = Some(Tally {
3213            first_choice,
3214            borda,
3215            winner,
3216            rankings: tops.len(),
3217            unanimous_initial,
3218            deliberated,
3219            changed_votes,
3220            unanimous_final,
3221            tie_break,
3222            judges: judges_total,
3223            present,
3224            quorum,
3225            met_quorum,
3226            uncontested,
3227        });
3228        self.state.status = if met_quorum {
3229            RunStatus::Reviewing
3230        } else {
3231            RunStatus::Stalled
3232        };
3233        self.state.save()?;
3234        Ok(())
3235    }
3236
3237    // ------------------------------------------------------------- recover
3238
3239    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3240    /// resumed toward completion once the transient cause clears.
3241    ///
3242    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3243    /// it toward the quorum, which is exactly the set of seats whose absence
3244    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3245    /// not depend on which node happened to hit the limit), or an ordinary
3246    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3247    /// seat is never disturbed.
3248    ///
3249    /// A seat that now answers with a usable ranking is "recovered": its
3250    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3251    /// `tally` counts it present again), and its vote re-collected. A seat that
3252    /// still fails keeps its loss and stays absent.
3253    ///
3254    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3255    /// to review/gate/merge), `false` when it is still below quorum (the run
3256    /// stays `Stalled`, still resumable for a later retry).
3257    #[allow(clippy::too_many_lines)]
3258    async fn recover_stall(&mut self) -> Result<bool> {
3259        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3260        // agent files with `magi task add` name the run that paid for it. The
3261        // prompt overlay is cloned alongside it because the waves borrow it
3262        // while `self` is mutably borrowed by the node's own bookkeeping.
3263        let run_id = self.state.id.clone();
3264        let prompts = self.state.config.prompts.clone();
3265        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3266        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3267        // is what keeps a non-quota collapse — or a quota loss recorded at the
3268        // deliberate node — from being a permanent dead-end on `--resume`.
3269        let quota_seats: BTreeSet<&str> =
3270            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3271        let absent: Vec<String> = self
3272            .state
3273            .judgements
3274            .iter()
3275            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3276            .map(|j| j.seat.clone())
3277            .collect();
3278        if absent.is_empty() {
3279            return Ok(false);
3280        }
3281        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3282        if viable.len() <= 1 {
3283            return Ok(false);
3284        }
3285        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3286        let language = self.state.config.graph.language.clone();
3287        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3288        let sessions = self.state.config.graph.sessions;
3289        let artifacts = agent::artifacts_dir(&self.state.dir());
3290        let root = self.state.worktree_root();
3291        let base_short = short(&self.state.base_commit);
3292        let candidates: Vec<Candidate> = viable.clone();
3293
3294        // Map each absent seat key to its 0-based position in `roles.judges`.
3295        let mut positions: Vec<usize> = absent
3296            .iter()
3297            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3298            .collect();
3299        if positions.is_empty() {
3300            return Ok(false);
3301        }
3302        positions.sort_unstable();
3303        positions.dedup();
3304
3305        // Re-rank the lost seats, one blind prompt each.
3306        let mut judge_jobs = Vec::new();
3307        for &j in &positions {
3308            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3309            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3310            let seat_key = format!("judge-{}", j + 1);
3311            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3312            let seat = self.seat(&seat_key, &spec.id);
3313            judge_jobs.push(SeatJob {
3314                spec,
3315                seat,
3316                prompt: prompt::judge(
3317                    &self.state.instruction,
3318                    &views,
3319                    self.roles.judges.len(),
3320                    &base_short,
3321                    &language,
3322                ),
3323                cwd: root.join(seat_key),
3324                timeout,
3325                allow_write: false,
3326                sessions,
3327                artifacts: artifacts.clone(),
3328                stem: format!("judge-{}-recover", j + 1),
3329                handover: None,
3330            });
3331        }
3332
3333        let labels_for_check = labels.clone();
3334        let mut judge_losses = Vec::new();
3335        let retries = self.state.config.graph.retries;
3336        let cache = self.state.config.cache_dir();
3337        let ctx = WaveCtx {
3338            carry_seats: false,
3339            run: &run_id,
3340            node: "judge",
3341            prompts: &prompts,
3342            cache: cache.as_deref(),
3343            round: None,
3344        };
3345        let results = ask_json_wave::<Ranking>(
3346            judge_jobs,
3347            Arc::clone(&self.sem),
3348            retries,
3349            &self.roles.judge_roster,
3350            &ctx,
3351            &mut judge_losses,
3352            &mut self.state,
3353            &move |r: &Ranking| r.validate(&labels_for_check),
3354        )
3355        .await;
3356
3357        // Refresh the judgement of every seat that ranked again.
3358        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3359        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3360            let agent_id = seat.agent.clone();
3361            self.state.seats.insert(seat.key.clone(), seat);
3362            let record = &mut self.state.judgements[j];
3363            match res {
3364                Ok((ranking, out)) => {
3365                    record.agent = agent_id;
3366                    record.ranking = ranking.normalized();
3367                    record.reasons = ranking.reasons;
3368                    record.strengths = ranking.strengths;
3369                    record.confidence = ranking.confidence;
3370                    record.failed = None;
3371                    record.duration_ms = out.duration_ms;
3372                    recovered.insert(j);
3373                    self.state.event(
3374                        "recover",
3375                        format!("judge {} ranked again after the limit", j + 1),
3376                    );
3377                }
3378                Err(e) => {
3379                    self.state
3380                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3381                }
3382            }
3383        }
3384
3385        // Re-ask the votes of the seats that recovered a ranking.
3386        let mut vote_jobs = Vec::new();
3387        let mut vote_pos: Vec<usize> = Vec::new();
3388        for &j in &recovered {
3389            let seat_key = format!("judge-{}", j + 1);
3390            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3391            let seat = self.seat(&seat_key, &spec.id);
3392            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3393            let text = if has_context(&spec, &seat, sessions) {
3394                prompt::final_vote(&labels, &language)
3395            } else {
3396                full.clone()
3397            };
3398            vote_jobs.push(SeatJob {
3399                spec,
3400                seat,
3401                prompt: text,
3402                cwd: root.join(seat_key),
3403                timeout,
3404                allow_write: false,
3405                sessions,
3406                artifacts: artifacts.clone(),
3407                stem: format!("vote-judge-{}-recover", j + 1),
3408                handover: Some(full),
3409            });
3410            vote_pos.push(j);
3411        }
3412        let allowed = labels.clone();
3413        let mut vote_losses = Vec::new();
3414        let vote_retries = self.state.config.graph.retries;
3415        let vote_cache = self.state.config.cache_dir();
3416        let ctx = WaveCtx {
3417            carry_seats: false,
3418            run: &run_id,
3419            node: "vote",
3420            prompts: &prompts,
3421            cache: vote_cache.as_deref(),
3422            round: None,
3423        };
3424        let votes = ask_json_wave::<FinalVote>(
3425            vote_jobs,
3426            Arc::clone(&self.sem),
3427            vote_retries,
3428            &self.roles.judge_roster,
3429            &ctx,
3430            &mut vote_losses,
3431            &mut self.state,
3432            &move |v: &FinalVote| match v.label() {
3433                Some(c) if allowed.contains(&c) => Ok(()),
3434                other => bail!("vote {other:?} is not one of {allowed:?}"),
3435            },
3436        )
3437        .await;
3438        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3439            let agent_id = seat.agent.clone();
3440            self.state.seats.insert(seat.key.clone(), seat);
3441            match res {
3442                Ok((v, _)) => {
3443                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3444                        rec.vote = v.label();
3445                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3446                    } else {
3447                        self.state.votes.push(VoteRecord {
3448                            judge: j + 1,
3449                            agent: agent_id,
3450                            vote: v.label(),
3451                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3452                            changed: false,
3453                        });
3454                    }
3455                    self.state.event(
3456                        "recover",
3457                        format!("judge {} voted again after the limit", j + 1),
3458                    );
3459                }
3460                Err(e) => {
3461                    self.state
3462                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3463                }
3464            }
3465        }
3466
3467        // A seat that ranked again is present even if its re-vote failed —
3468        // `tally` falls back to the initial ranking's first choice — so clear
3469        // its quota loss. Seats that still fail keep theirs and stay absent.
3470        let recovered_keys: BTreeSet<String> = recovered
3471            .iter()
3472            .map(|&j| format!("judge-{}", j + 1))
3473            .collect();
3474        self.state
3475            .quota
3476            .retain(|q| !recovered_keys.contains(&q.seat));
3477        // A seat that hit the limit again is a fresh loss, not the old one:
3478        // replace the stale entry so the history stays one-per-seat and the
3479        // daemon can tell this attempt's loss from a previous session's.
3480        for loss in judge_losses.into_iter().chain(vote_losses) {
3481            if recovered_keys.contains(&loss.seat) {
3482                continue;
3483            }
3484            self.state.quota.retain(|q| q.seat != loss.seat);
3485            self.state.quota.push(loss);
3486        }
3487
3488        // Recompute the verdict from the refreshed panel.
3489        self.state.tally = None;
3490        self.tally()?;
3491        Ok(self
3492            .state
3493            .tally
3494            .as_ref()
3495            .map(|t| t.met_quorum)
3496            .unwrap_or(false))
3497    }
3498
3499    // ---------------------------------------------------------------- graft
3500
3501    /// Port the strengths the judges attributed to the losing candidates onto
3502    /// the winner, once, between the tally and the review loop.
3503    ///
3504    /// Best-effort by construction: it returns nothing and fails nothing. A
3505    /// skip, a fixer that failed or hit quota, a round that changed nothing
3506    /// and a result over the size limit ([`graft::limit`]) all leave the
3507    /// winner exactly at the commit it had before (`GraftRecord::pre`) and the
3508    /// run carries on as if the phase were off. See [`graft`] for the
3509    /// decisions and `AGENTS.md` for the invariants.
3510    ///
3511    /// The one exception is a rollback that could not be confirmed: the
3512    /// rejected commit may still be the winner's tip, so the run is blocked
3513    /// (resumable; the record stays `Running`, which makes a resume retry the
3514    /// restore) instead of reviewing, gating and merging it.
3515    async fn graft(&mut self) -> Result<()> {
3516        let Err(e) = self.graft_inner().await else {
3517            return Ok(());
3518        };
3519        if e.downcast_ref::<GraftRestoreFailed>().is_some() {
3520            self.state.status = RunStatus::Blocked;
3521            self.state.event("graft", format!("blocked: {e:#}"));
3522            self.state.save()?;
3523            return Err(e);
3524        }
3525        // An error after the `Running` record was saved may have left the
3526        // fixer's work in the tree: the record must not stay unsettled
3527        // (a later resume would reset past the review fixes), so confirm the
3528        // restore now, or block.
3529        if let Some(rec) = self.state.graft.clone()
3530            && !rec.outcome.settled()
3531        {
3532            if let Some(winner) = self.state.winner().cloned() {
3533                if let Err(re) = Self::graft_restore(&winner.worktree, &rec.pre).await {
3534                    self.state.status = RunStatus::Blocked;
3535                    self.state.event("graft", format!("blocked: {re:#}"));
3536                    self.state.save()?;
3537                    return Err(re);
3538                }
3539            }
3540            if let Some(g) = self.state.graft.as_mut() {
3541                g.outcome = GraftOutcome::RolledBack(format!("{e:#}"));
3542            }
3543        }
3544        self.state.event("graft", format!("skipped: {e:#}"));
3545        if let Err(e) = self.state.save() {
3546            tracing::warn!("save after graft: {e:#}");
3547        }
3548        Ok(())
3549    }
3550
3551    /// Put the winner back on `pre` and confirm it: a failed `reset` (a stale
3552    /// `index.lock`, say) or a tip that is not `pre` afterwards is an error.
3553    async fn graft_restore(wt: &Path, pre: &str) -> Result<()> {
3554        let restored = git::restore_to(wt, pre).await;
3555        let tip = git::rev_parse(wt, "HEAD").await;
3556        match (restored, tip) {
3557            (Ok(()), Ok(tip)) if tip == pre => Ok(()),
3558            (Err(e), _) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3559            (Ok(()), Ok(tip)) => {
3560                Err(GraftRestoreFailed(format!("the winner is at {tip}, not {pre}")).into())
3561            }
3562            (Ok(()), Err(e)) => Err(GraftRestoreFailed(format!("{e:#}")).into()),
3563        }
3564    }
3565
3566    /// A skip is a run event, recorded once however often the node is re-entered.
3567    fn graft_skipped(&mut self, why: &str) {
3568        let message = format!("skipped: {why}");
3569        if !self
3570            .state
3571            .events
3572            .iter()
3573            .any(|e| e.node == "graft" && e.message == message)
3574        {
3575            self.state.event("graft", message);
3576        }
3577    }
3578
3579    async fn graft_inner(&mut self) -> Result<()> {
3580        // A round that was started and never finished (crash, kill): the
3581        // fixer may have left anything in the tree, so put the winner back.
3582        if let Some(rec) = self.state.graft.clone()
3583            && !rec.outcome.settled()
3584        {
3585            // Reviews exist only if the graft ended without its record being
3586            // settled (a swallowed error): the tree has moved on since, and
3587            // a reset to `pre` would discard the review fixes.
3588            if self.state.reviews.is_empty()
3589                && let Some(winner) = self.state.winner().cloned()
3590            {
3591                Self::graft_restore(&winner.worktree, &rec.pre).await?;
3592            }
3593            if let Some(g) = self.state.graft.as_mut() {
3594                g.outcome = GraftOutcome::RolledBack(graft::INTERRUPTED.to_owned());
3595            }
3596            self.state.event(
3597                "graft",
3598                "rolled back: the previous round was interrupted; the winner is untouched",
3599            );
3600            self.state.save()?;
3601            return Ok(());
3602        }
3603        let plan = match graft::plan(&self.state) {
3604            Ok(plan) => plan,
3605            Err(skip) => {
3606                if skip != graft::Skip::AlreadyDone {
3607                    self.graft_skipped(skip.reason());
3608                    self.state.save()?;
3609                }
3610                return Ok(());
3611            }
3612        };
3613        let Some(winner) = self.state.winner().cloned() else {
3614            return Ok(());
3615        };
3616        let wt = winner.worktree.clone();
3617        if !git::is_clean(&wt).await? {
3618            self.graft_skipped("the winner's worktree has uncommitted changes");
3619            self.state.save()?;
3620            return Ok(());
3621        }
3622        let pre = git::rev_parse(&wt, "HEAD").await?;
3623        let Some(winner_lines) = git::changed_lines(&wt, &self.state.base_commit, &pre).await
3624        else {
3625            self.graft_skipped("the winner's diff could not be measured");
3626            self.state.save()?;
3627            return Ok(());
3628        };
3629        let limit = graft::limit(winner_lines);
3630        // Saved before the fixer is called: a stop from here on is repaired
3631        // by the branch above on the next entry.
3632        self.state.graft = Some(GraftRecord {
3633            pre: pre.clone(),
3634            post: None,
3635            lines: None,
3636            limit: Some(limit),
3637            agent: None,
3638            outcome: GraftOutcome::Running,
3639        });
3640        self.state.event(
3641            "graft",
3642            format!(
3643                "porting strengths of candidate(s) {} onto {} (limit {limit} changed lines)",
3644                plan.strengths
3645                    .iter()
3646                    .map(|(l, _, _)| l.to_string())
3647                    .collect::<Vec<_>>()
3648                    .join(", "),
3649                plan.winner
3650            ),
3651        );
3652        self.state.save()?;
3653
3654        let fix_prompt = prompt::graft(
3655            &self.state.instruction,
3656            plan.winner,
3657            &plan.strengths,
3658            limit,
3659            &self.state.config.graph.language,
3660        );
3661        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
3662        let sessions = self.state.config.graph.sessions;
3663        let artifacts = agent::artifacts_dir(&self.state.dir());
3664        let attempts = fixer::graft_attempts(&self.state, &self.roles, &winner);
3665        let (_, seat, out) = self
3666            .ask_fixer_among(attempts, "graft", None, Some((&wt, &pre)), |spec, seat| {
3667                SeatJob {
3668                    prompt: fix_prompt.clone(),
3669                    spec,
3670                    seat,
3671                    cwd: wt.clone(),
3672                    timeout,
3673                    allow_write: true,
3674                    sessions,
3675                    artifacts: artifacts.clone(),
3676                    stem: "graft".to_owned(),
3677                    handover: None,
3678                }
3679            })
3680            .await;
3681        let mut why: Option<String> = None;
3682        match &out {
3683            AgentOutcome::Ok(o) if agent::output_advances(o) => {
3684                why = Some("the fixer produced nothing usable".to_owned());
3685            }
3686            AgentOutcome::Ok(_) => {}
3687            AgentOutcome::Dropped(_) => why = Some("the CLI dropped the stream".to_owned()),
3688            AgentOutcome::Quota(o) => {
3689                self.state.quota.push(QuotaLoss {
3690                    seat: seat.key.clone(),
3691                    node: "graft".to_owned(),
3692                    at: Timestamp::now(),
3693                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
3694                });
3695                why = Some("rate limited (quota)".to_owned());
3696            }
3697            AgentOutcome::Failed(e) => {
3698                why = Some(format!(
3699                    "the fixer failed: {}",
3700                    e.lines()
3701                        .next()
3702                        .unwrap_or("")
3703                        .chars()
3704                        .take(160)
3705                        .collect::<String>()
3706                ));
3707            }
3708        }
3709        let agent_id = seat.agent.clone();
3710        self.state.seats.insert(seat.key.clone(), seat);
3711
3712        let mut post = None;
3713        let mut lines = None;
3714        if why.is_none() {
3715            let rescued = git::rescue_commit(&wt, "magi: graft (uncommitted work)").await;
3716            if let Ok(r) = &rescued {
3717                self.state.note_withheld("graft", &r.withheld);
3718            }
3719            let head = git::rev_parse(&wt, "HEAD").await?;
3720            // What is measured must be what gets reviewed and gated: a failed
3721            // rescue, or anything still uncommitted (withheld lockfiles
3722            // aside, which are never part of the tree), rejects the graft.
3723            let dirty = match &rescued {
3724                Ok(r) => git::status_porcelain(&wt)
3725                    .await
3726                    .map(|st| {
3727                        st.lines().any(|l| {
3728                            !r.withheld
3729                                .iter()
3730                                .any(|w| l.trim_end().ends_with(w.path.as_str()))
3731                        })
3732                    })
3733                    .unwrap_or(true),
3734                Err(_) => true,
3735            };
3736            if dirty {
3737                why = Some("the fixer left changes that could not be committed".to_owned());
3738            } else if head == pre {
3739                why = Some("the fixer changed nothing".to_owned());
3740            } else {
3741                lines = git::changed_lines(&wt, &pre, &head).await;
3742                if graft::within_budget(lines, limit) {
3743                    post = Some(head);
3744                } else {
3745                    why = Some(format!(
3746                        "{} changed lines against a limit of {limit}",
3747                        lines.map_or_else(
3748                            || "an unmeasurable number of".to_owned(),
3749                            |l| l.to_string()
3750                        )
3751                    ));
3752                }
3753            }
3754        }
3755        let outcome = match why {
3756            None => {
3757                self.state.event(
3758                    "graft",
3759                    format!(
3760                        "applied: {} changed lines (limit {limit})",
3761                        lines.unwrap_or(0)
3762                    ),
3763                );
3764                GraftOutcome::Applied
3765            }
3766            Some(why) => {
3767                // Not settled until the restore is confirmed: the record
3768                // stays `Running`, so a resume retries it.
3769                Self::graft_restore(&wt, &pre).await?;
3770                self.state.event(
3771                    "graft",
3772                    format!("rolled back: {why}; the winner is untouched"),
3773                );
3774                GraftOutcome::RolledBack(why)
3775            }
3776        };
3777        self.state.graft = Some(GraftRecord {
3778            pre,
3779            post,
3780            lines,
3781            limit: Some(limit),
3782            agent: Some(agent_id),
3783            outcome,
3784        });
3785        self.state.save()?;
3786        Ok(())
3787    }
3788
3789    // ----------------------------------------------------------------- fold
3790
3791    async fn fold_losers(&mut self) -> Result<()> {
3792        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3793            return Ok(());
3794        };
3795        let repo = self.state.repo.clone();
3796        let mut folded = Vec::new();
3797        for i in 0..self.state.candidates.len() {
3798            let c = &self.state.candidates[i];
3799            if c.label == winner || c.folded {
3800                continue;
3801            }
3802            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3803            git::worktree_remove(&repo, &wt).await.ok();
3804            git::branch_delete(&repo, &branch).await.ok();
3805            self.state.candidates[i].folded = true;
3806            folded.push(label.to_string());
3807        }
3808        // The judges are finished; their checkouts are pure cost from here.
3809        let root = self.state.worktree_root();
3810        for j in 1..=self.roles.judges.len() {
3811            let wt = root.join(format!("judge-{j}"));
3812            if wt.exists() {
3813                git::worktree_remove(&repo, &wt).await.ok();
3814            }
3815        }
3816        // The design-deliberation stage is finished by the time a tally
3817        // exists — same reasoning as the judges above.
3818        if self.state.config.graph.advise {
3819            for k in 1..=self.state.config.graph.advisors {
3820                let wt = root.join(format!("advisor-{k}"));
3821                if wt.exists() {
3822                    git::worktree_remove(&repo, &wt).await.ok();
3823                }
3824            }
3825        }
3826        if !folded.is_empty() {
3827            self.state
3828                .event("fold", format!("folded candidates {}", folded.join(", ")));
3829            self.state.save()?;
3830        }
3831        Ok(())
3832    }
3833
3834    // ------------------------------------------------------------ base sync
3835
3836    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3837    /// anything verifies it.
3838    ///
3839    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3840    /// read whatever is checked out in the winner's worktree. Left alone that
3841    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3842    /// it when the run *branched* - and a run takes long enough that the base
3843    /// has usually moved by the time it gets here. A gate that ran there
3844    /// answers "green on the commit this run started from", not "green on
3845    /// what is about to land", and the difference showed up three times in
3846    /// one day as a green run whose merge would have reverted a file another
3847    /// pull request had already landed.
3848    ///
3849    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3850    /// `land::Step::Rebase` calls, rather than a second implementation of the
3851    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3852    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3853    /// doc warns about ("two answers to a question nobody notices until a
3854    /// diff is wrong").
3855    ///
3856    /// A conflict is not the end of the road: the standing rebase is handed
3857    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3858    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3859    /// land). Once it finishes, review and the gate run as usual on the
3860    /// rebased tree, which is where a breakage the new base caused is caught
3861    /// by the ordinary gate-fix round. magi resolves nothing itself.
3862    ///
3863    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3864    /// `state.base_sync.attempts`, is how many times the base is *rebased
3865    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3866    /// *conflict* was given to a fixer. When the fixer cannot finish the
3867    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3868    /// what was tried (rounds spent, paths still conflicted) and the branch
3869    /// and worktree stay exactly as they were - untouched, for a person to
3870    /// look at - which is also what makes re-entering this function
3871    /// afterwards a no-op instead of a second attempt at the same wall. A
3872    /// push failure ends the same way.
3873    async fn sync_to_base(&mut self) -> Result<()> {
3874        if self.state.status == RunStatus::AlreadyInBase {
3875            return Ok(());
3876        }
3877        let conflicted = self
3878            .state
3879            .base_sync
3880            .as_ref()
3881            .is_some_and(|s| s.conflict.is_some());
3882        let Some(winner) = self.state.winner().cloned() else {
3883            return Ok(());
3884        };
3885
3886        let repo = self.state.repo.clone();
3887        let remote = self.state.config.merge.remote.clone();
3888        let base_branch = self.state.base_branch.clone();
3889        let tracking = format!("{remote}/{base_branch}");
3890
3891        git::fetch(&repo, &remote, &base_branch).await.ok();
3892        // No network, or the remote never had this branch: the run already
3893        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
3894        // otherwise), and one that got this far is not blocked by a fetch
3895        // that fails now. It just has no newer tip to compare against.
3896        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3897            return Ok(());
3898        };
3899
3900        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3901        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3902        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3903
3904        // Before any rebase, and before a recorded conflict is honoured: a
3905        // branch whose change reached the base under other commit ids has
3906        // nothing to rebase and nothing to conflict with, and a run that
3907        // already stopped on that phantom conflict recovers here on resume.
3908        // `behind == 0` with head == tip is a branch the base has since taken
3909        // in whole, whether or not a conflict was ever recorded: the ancestry
3910        // proof must still run (`classify` ignores a head still on the start
3911        // commit).
3912        if (behind > 0 || conflicted || head == tip)
3913            && self
3914                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3915                .await?
3916        {
3917            return Ok(());
3918        }
3919        if conflicted {
3920            return Ok(());
3921        }
3922
3923        if behind == 0 {
3924            // A fixer-finished rebase moves the branch ref before the
3925            // winner's worktree is told (`sync_to_head` below). A run that
3926            // died in between resumes here with `behind == 0` and a tree still
3927            // holding the pre-rebase files, which review and the gate would
3928            // then read. That state is exactly: HEAD moved off the tip the
3929            // rebase started from, yet the tree is still identical to that
3930            // tip. A tree with edits of its own differs from it, so nothing
3931            // is thrown away.
3932            if let Some(from) = self
3933                .state
3934                .rebase_fixes
3935                .iter()
3936                .rev()
3937                .find_map(|r| r.from.clone())
3938                && from != head
3939                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3940                    .await
3941                    .is_ok_and(|o| o.ok())
3942            {
3943                git::sync_to_head(&winner.worktree).await?;
3944            }
3945            // An earlier attempt may have rebased the branch locally and died
3946            // before pushing it (only the fresh-rebase arm below pushes).
3947            // Publish it now, so the plain push at PR time is not refused as
3948            // a non-fast-forward. A run already holding a recorded conflict
3949            // never reaches here; that case is out of scope.
3950            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3951            if let Some(why) = &conflict {
3952                self.state.status = RunStatus::Blocked;
3953                self.state.event("land", why.clone());
3954            }
3955            self.state.base_sync = Some(BaseSync {
3956                tip,
3957                behind: 0,
3958                attempts,
3959                conflict,
3960                already_in: None,
3961            });
3962            self.state.save()?;
3963            return Ok(());
3964        }
3965
3966        if attempts >= BASE_SYNC_ROUNDS {
3967            let why = format!(
3968                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3969                 rebase(s); rebasing again would only race it",
3970                winner.branch
3971            );
3972            self.state.status = RunStatus::Blocked;
3973            self.state.base_sync = Some(BaseSync {
3974                tip,
3975                behind,
3976                attempts,
3977                conflict: Some(why.clone()),
3978                already_in: None,
3979            });
3980            self.state.event("land", why);
3981            self.state.save()?;
3982            return Ok(());
3983        }
3984
3985        self.state.event(
3986            "land",
3987            format!(
3988                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3989                winner.branch
3990            ),
3991        );
3992        self.state.save()?;
3993
3994        // The remote's copy of the branch, read now and only if the fetch
3995        // really succeeded (a stale tracking ref must never pin a lease). It is
3996        // pushed over after a rebase only when it is a commit this branch
3997        // already contains, by ancestry or by patch (an earlier rebase of ours
3998        // that never reached the remote): anything else is somebody else's work.
3999        let branch_tracking = format!("{remote}/{}", winner.branch);
4000        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
4001        let remote_tip = if self.state.task_pr_branch().is_none()
4002            && matches!(&fetched_branch, Ok(o) if o.ok())
4003        {
4004            git::rev_parse(&repo, &branch_tracking).await.ok()
4005        } else {
4006            None
4007        };
4008        // A remote tip this branch does not contain is somebody else's work:
4009        // rebasing would leave a local tip that can never be pushed. Stop
4010        // before touching anything and say so.
4011        if let Some(theirs) = &remote_tip
4012            && !git::is_ancestor(&repo, theirs, &head).await
4013            && !crate::reconcile::origin_missing(&repo, &head, theirs)
4014                .await
4015                .is_ok_and(|missing| missing.is_empty())
4016        {
4017            let why = format!(
4018                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
4019                 them",
4020                short(theirs),
4021                winner.branch
4022            );
4023            self.state.status = RunStatus::Blocked;
4024            self.state.base_sync = Some(BaseSync {
4025                tip,
4026                behind,
4027                attempts,
4028                conflict: Some(why.clone()),
4029                already_in: None,
4030            });
4031            self.state.event("land", why);
4032            self.state.save()?;
4033            return Ok(());
4034        }
4035
4036        let scratch = self.state.dir().join("base-sync");
4037        let rebased = match crate::rebase::rebase_with_fixer(
4038            &mut self.state,
4039            &scratch,
4040            &winner.branch,
4041            &tracking,
4042        )
4043        .await
4044        {
4045            Ok(crate::rebase::Rebased::Applied) => Ok(None),
4046            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
4047            Err(e) => Err(e),
4048        };
4049        let attempts = attempts + 1;
4050        match rebased {
4051            Ok(None) => {
4052                // The branch ref moved, but a worktree that already had it
4053                // checked out (the winner's) was not told; sync its index and
4054                // files before anything reads them.
4055                git::sync_to_head(&winner.worktree).await?;
4056                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
4057                let mut conflict = None;
4058                if let Some(pinned) = &remote_tip {
4059                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
4060                    match pushed {
4061                        Ok(o) if o.ok() => self.state.event(
4062                            "land",
4063                            format!("pushed rebased {} to {remote}", winner.branch),
4064                        ),
4065                        Ok(o) => {
4066                            conflict = Some(format!(
4067                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
4068                                winner.branch,
4069                                short(pinned),
4070                                o.stderr.chars().take(600).collect::<String>()
4071                            ));
4072                        }
4073                        Err(e) => {
4074                            conflict = Some(format!(
4075                                "rebased {} locally but could not push it: {e:#}",
4076                                winner.branch
4077                            ));
4078                        }
4079                    }
4080                }
4081                if let Some(why) = &conflict {
4082                    self.state.status = RunStatus::Blocked;
4083                    self.state.event("land", why.clone());
4084                }
4085                self.state.base_sync = Some(BaseSync {
4086                    tip: tip.clone(),
4087                    behind: 0,
4088                    attempts,
4089                    conflict,
4090                    already_in: None,
4091                });
4092                self.state
4093                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
4094            }
4095            Ok(Some(conflict)) => {
4096                let why = format!(
4097                    "{} conflicts with {tracking} and did not rebase: {}",
4098                    winner.branch,
4099                    conflict.chars().take(600).collect::<String>()
4100                );
4101                self.state.status = RunStatus::Blocked;
4102                self.state.base_sync = Some(BaseSync {
4103                    tip,
4104                    behind,
4105                    attempts,
4106                    conflict: Some(why.clone()),
4107                    already_in: None,
4108                });
4109                self.state.event("land", why);
4110            }
4111            Err(e) => {
4112                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
4113                self.state.status = RunStatus::Blocked;
4114                self.state.base_sync = Some(BaseSync {
4115                    tip,
4116                    behind,
4117                    attempts,
4118                    conflict: Some(why.clone()),
4119                    already_in: None,
4120                });
4121                self.state.event("land", why);
4122            }
4123        }
4124        self.state.save()?;
4125        Ok(())
4126    }
4127
4128    /// Push a branch an earlier attempt rebased locally but never published,
4129    /// pinned to the remote tip read right after a successful fetch. Returns
4130    /// the reason when the run must stop; `None` when there was nothing to do
4131    /// (no remote copy, the same tip, or a remote copy this branch already
4132    /// contains, which the PR-time push fast-forwards) or the push succeeded.
4133    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
4134        // Task competitions publish only after the PR text gate, to their
4135        // public head; never resurrect a candidate branch on the remote.
4136        if self.state.task_pr_branch().is_some() {
4137            return None;
4138        }
4139        let repo = self.state.repo.clone();
4140        let remote = self.state.config.merge.remote.clone();
4141        let fetched = git::fetch(&repo, &remote, branch).await;
4142        if !matches!(&fetched, Ok(o) if o.ok()) {
4143            return None;
4144        }
4145        let branch_tracking = format!("{remote}/{branch}");
4146        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
4147        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
4148            return None;
4149        }
4150        if !crate::reconcile::origin_missing(&repo, head, &theirs)
4151            .await
4152            .is_ok_and(|missing| missing.is_empty())
4153        {
4154            return Some(format!(
4155                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
4156                 them",
4157                short(&theirs)
4158            ));
4159        }
4160        match git::push_pinned(&repo, &remote, branch, &theirs).await {
4161            Ok(o) if o.ok() => {
4162                self.state
4163                    .event("land", format!("pushed rebased {branch} to {remote}"));
4164                None
4165            }
4166            Ok(o) => Some(format!(
4167                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
4168                 someone may have pushed): {}",
4169                short(&theirs),
4170                o.stderr.chars().take(600).collect::<String>()
4171            )),
4172            Err(e) => Some(format!(
4173                "{branch} is rebased locally but could not be pushed: {e:#}"
4174            )),
4175        }
4176    }
4177
4178    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
4179    /// is already on `tip` under other commit ids ([`crate::already`]); returns
4180    /// whether it did.
4181    ///
4182    /// Checked only when the base is ahead of the branch. A failing check is
4183    /// "not proven" - the ordinary rebase path then decides - never a reason to
4184    /// stop the run.
4185    ///
4186    /// The remote copy of the branch is held to the same standard as the local
4187    /// one: if it carries a tip this worktree does not, that tip must itself be
4188    /// proven in the base, or nothing is settled (a pull request would
4189    /// otherwise be closed over commits nobody checked). The pull request is
4190    /// closed *before* the terminal status is saved; if that fails for a
4191    /// reason other than a refusal (no network, a `gh` error) the run is left
4192    /// `Blocked` with the reason as its conflict, which a resume retries -
4193    /// the same recovery a phantom conflict gets.
4194    async fn settle_already_in(
4195        &mut self,
4196        branch: &str,
4197        tip: &str,
4198        head: &str,
4199        attempts: usize,
4200        behind: usize,
4201    ) -> Result<bool> {
4202        let repo = self.state.repo.clone();
4203        let remote = self.state.config.merge.remote.clone();
4204        let start = self.state.base_commit.clone();
4205        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
4206            Ok(Some(e)) => e,
4207            Ok(None) => return Ok(false),
4208            Err(e) => {
4209                tracing::warn!("already-in-base check for {branch}: {e:#}");
4210                return Ok(false);
4211            }
4212        };
4213        let mut verified = vec![head.to_owned()];
4214        let public_branch = self.state.pr_branch().unwrap_or(branch).to_owned();
4215        let fetched = git::fetch(&repo, &remote, &public_branch).await;
4216        if matches!(&fetched, Ok(o) if o.ok())
4217            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{public_branch}")).await
4218            && theirs != head
4219        {
4220            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
4221                Ok(Some(_)) => verified.push(theirs),
4222                _ => return Ok(false),
4223            }
4224        }
4225        let base_branch = self.state.base_branch.clone();
4226        let message = format!(
4227            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
4228             land",
4229            evidence.names(),
4230            evidence.proof.as_str()
4231        );
4232        let closed =
4233            crate::land::close_superseded_pr(&mut self.state, &public_branch, &evidence, &verified)
4234                .await;
4235        match closed {
4236            Ok(Ok(url)) => self
4237                .state
4238                .event("land", format!("closed {url}: superseded on {base_branch}")),
4239            Ok(Err(why)) => self
4240                .state
4241                .event("land", format!("did not close a pull request: {why}")),
4242            Err(e) => {
4243                let why = format!(
4244                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
4245                     not be closed ({e:#}); resume to retry"
4246                );
4247                self.state.status = RunStatus::Blocked;
4248                self.state.base_sync = Some(BaseSync {
4249                    tip: tip.to_owned(),
4250                    behind,
4251                    attempts,
4252                    conflict: Some(why.clone()),
4253                    already_in: None,
4254                });
4255                self.state.event("land", why);
4256                self.state.save()?;
4257                return Ok(true);
4258            }
4259        }
4260        self.state.status = RunStatus::AlreadyInBase;
4261        self.state.base_sync = Some(BaseSync {
4262            tip: tip.to_owned(),
4263            behind,
4264            attempts,
4265            conflict: None,
4266            already_in: Some(evidence),
4267        });
4268        self.state.event("land", message);
4269        self.state.save()?;
4270        self.settle_questions();
4271        Ok(true)
4272    }
4273
4274    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
4275    /// last landed the winner on, once it has run, else the commit the run
4276    /// branched from.
4277    ///
4278    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
4279    /// and `vote` all happen before there is a winner to rebase, so they
4280    /// compare every candidate against the branch point on purpose, and a
4281    /// base that moves after they are already done cannot change an answer
4282    /// they already gave.
4283    fn landing_base(&self) -> String {
4284        self.state
4285            .base_sync
4286            .as_ref()
4287            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
4288    }
4289
4290    // ------------------------------------------------------- operator fix
4291
4292    /// Route specific, already-recorded review findings to a fixer for a
4293    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
4294    /// entry point.
4295    ///
4296    /// Distinct from `review_loop`'s own fix step in three ways: it never
4297    /// runs a reviewer wave, it never spends review-round budget, and what
4298    /// happened is recorded as an [`OperatorFixRequest`] appended to
4299    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
4300    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
4301    /// and vote must never be rewritten to look like a manufactured blocking
4302    /// verdict.
4303    ///
4304    /// Only meaningful once review has actually concluded: `Ready` (handed
4305    /// off with findings still open, or simply concluded clean while minor
4306    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
4307    /// gate failed). Everything else is refused: a run still in progress
4308    /// should simply be resumed, and a `Merged` run's branch has already
4309    /// landed — reopening *this* run's own record cannot change that, so the
4310    /// answer there is a fresh `magi review <branch>`.
4311    ///
4312    /// A real commit here re-verifies through a fresh, ordinary review-only
4313    /// run on the same branch ([`Self::review`]) rather than reopening this
4314    /// run's own `review_loop`: once any round in this run's history went
4315    /// clean, `review_conclusion` treats that as permanent by design (the
4316    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
4317    /// way to force one more genuine reviewer wave out of *this* run without
4318    /// either rewriting history or weakening that guarantee for every other
4319    /// caller. A review-only run costs nothing extra — no implementation, no
4320    /// judging, no vote — and exercises the exact same review → verify →
4321    /// gate → (human) merge path, unmodified.
4322    pub async fn fix_selected(
4323        &mut self,
4324        ids: &[String],
4325        reason: &str,
4326        allow_stale: bool,
4327    ) -> Result<()> {
4328        let reason = reason.trim();
4329        if reason.is_empty() {
4330            bail!("a fix request needs a reason — that is the operator's own record of why");
4331        }
4332        if ids.is_empty() {
4333            bail!("no finding id given");
4334        }
4335        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
4336            bail!(
4337                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
4338                 has already concluded — can be given a targeted fix. A run still \
4339                 in progress should simply be resumed; a `merged` run's branch has \
4340                 already landed, so its answer is a fresh `magi review <branch>`, \
4341                 not reopening this run's own record",
4342                self.state.id,
4343                self.state.status.as_str()
4344            );
4345        }
4346        let Some(winner) = self.state.winner().cloned() else {
4347            bail!("run {} has no winning candidate to fix", self.state.id);
4348        };
4349        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4350            bail!(
4351                "branch `{}` no longer exists; this run cannot be extended",
4352                winner.branch
4353            );
4354        }
4355        let home = crate::run::home();
4356        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4357            bail!(
4358                "run {} is currently being worked on by another magi process",
4359                self.state.id
4360            );
4361        }
4362        // Held for the rest of this call, including the follow-up review
4363        // below: two `magi fix` invocations against the same run must not
4364        // both reach the worktree manipulation further down, which would
4365        // otherwise race to remove and recreate the same directory — see
4366        // [`FixClaim`]'s own doc.
4367        let _claim = FixClaim::acquire(&self.state.dir())?;
4368
4369        // Resolve every id before spending anything — an unknown id refuses
4370        // the whole request rather than silently dropping it — and dedup
4371        // while keeping the operator's own order.
4372        let mut seen = BTreeSet::new();
4373        let mut findings = Vec::new();
4374        let mut missing = Vec::new();
4375        for id in ids {
4376            if !seen.insert(id.clone()) {
4377                continue;
4378            }
4379            match self.state.finding(id) {
4380                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4381                    id: f.id.clone(),
4382                    severity: f.severity,
4383                    reviewer_vote: rec.vote,
4384                    round: round.round,
4385                    round_head: round.head.clone(),
4386                    reviewer: rec.reviewer,
4387                    agent: rec.agent.clone(),
4388                    file: f.file.clone(),
4389                    line: f.line,
4390                    title: f.title.clone(),
4391                    detail: f.detail.clone(),
4392                    outcome: OperatorFixOutcome::Pending,
4393                }),
4394                None => missing.push(id.clone()),
4395            }
4396        }
4397        if !missing.is_empty() {
4398            bail!(
4399                "unknown finding id(s): {}; nothing was changed",
4400                missing.join(", ")
4401            );
4402        }
4403
4404        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4405        let stale_details: Vec<(String, String)> = findings
4406            .iter()
4407            .filter(|f| f.round_head != head_at_request)
4408            .map(|f| (f.id.clone(), f.round_head.clone()))
4409            .collect();
4410        let stale = !stale_details.is_empty();
4411        if stale && !allow_stale {
4412            bail!(
4413                "the branch has moved since some finding(s) were raised — {} — now \
4414                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4415                stale_details
4416                    .iter()
4417                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4418                    .collect::<Vec<_>>()
4419                    .join(", "),
4420                short(&head_at_request)
4421            );
4422        }
4423
4424        let request = OperatorFixRequest {
4425            requested_at: Timestamp::now(),
4426            reason: reason.to_owned(),
4427            findings,
4428            head_at_request: head_at_request.clone(),
4429            allow_stale,
4430            stale,
4431            fix: None,
4432            result_head: None,
4433            follow_up_review_run: None,
4434        };
4435        self.state.event(
4436            "fix",
4437            format!(
4438                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4439                request.findings.len(),
4440                request
4441                    .findings
4442                    .iter()
4443                    .map(|f| f.id.as_str())
4444                    .collect::<Vec<_>>()
4445                    .join(", "),
4446            ),
4447        );
4448        // Recorded now, before any worktree work or the fixer call itself —
4449        // and re-saved at each checkpoint below: a crash at any point after
4450        // this (mid fixer call, mid follow-up review) must not lose the fact
4451        // that this was requested, for which findings, and why. Everything
4452        // past this point reads and writes through `request_index` rather
4453        // than a local variable, since `request` itself is moved here.
4454        self.state.operator_fixes.push(request);
4455        self.state.save()?;
4456        let request_index = self.state.operator_fixes.len() - 1;
4457
4458        // A fresh, dedicated worktree for this one call, never the winner's
4459        // own worktree in place: that one may already be gone (folded away),
4460        // and reusing it in place would leave the branch checked out there
4461        // when the follow-up review below tries to check it out again. Freed
4462        // immediately after, either way — but only once confirmed clean:
4463        // `worktree_remove` is a `git worktree remove --force`, which would
4464        // otherwise discard uncommitted work left there by the operator or
4465        // another process before this had a chance to even look at it.
4466        if winner.worktree.exists() {
4467            // Lockfiles a rescue commit withheld stay untracked on purpose and
4468            // are already recorded; they are not the operator's work to protect.
4469            let dirty = git::git(
4470                &winner.worktree,
4471                &["status", "--porcelain", "--untracked-files=all"],
4472            )
4473            .await?;
4474            let only_withheld = dirty.lines().all(|l| {
4475                l.strip_prefix("?? ")
4476                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4477            });
4478            if !only_withheld {
4479                bail!(
4480                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4481                     discard them first",
4482                    winner.worktree.display()
4483                );
4484            }
4485            git::worktree_remove(&self.state.repo, &winner.worktree)
4486                .await
4487                .ok();
4488        }
4489        let fix_worktree = self.state.worktree_root().join("operator-fix");
4490        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4491        git::git(
4492            &self.state.repo,
4493            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4494        )
4495        .await
4496        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4497        if !git::is_clean(&fix_worktree).await? {
4498            git::worktree_remove(&self.state.repo, &fix_worktree)
4499                .await
4500                .ok();
4501            bail!(
4502                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4503                winner.branch
4504            );
4505        }
4506        let repo_for_setup = self.state.repo.clone();
4507        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4508            Ok(()) => {
4509                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4510            }
4511            Err(e) => Err(e),
4512        };
4513        if let Err(e) = setup {
4514            git::worktree_remove(&self.state.repo, &fix_worktree)
4515                .await
4516                .ok();
4517            return Err(e);
4518        }
4519
4520        let run_id = self.state.id.clone();
4521        let prompts = self.state.config.prompts.clone();
4522        let language = self.state.config.graph.language.clone();
4523        let sessions = self.state.config.graph.sessions;
4524        let artifacts = agent::artifacts_dir(&self.state.dir());
4525        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4526            .findings
4527            .iter()
4528            .map(|f| Finding {
4529                id: f.id.clone(),
4530                severity: f.severity,
4531                file: f.file.clone(),
4532                line: f.line,
4533                title: f.title.clone(),
4534                detail: f.detail.clone(),
4535            })
4536            .collect();
4537        let fix_prompt = prompt::operator_fix(
4538            &self.state.instruction,
4539            &finding_list,
4540            reason,
4541            &stale_details,
4542            &head_at_request,
4543            &language,
4544        );
4545        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4546        let (job, seat, out) = self
4547            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4548                prompt: fix_prompt.clone(),
4549                spec,
4550                seat,
4551                cwd: fix_worktree.clone(),
4552                timeout,
4553                allow_write: true,
4554                sessions,
4555                artifacts: artifacts.clone(),
4556                stem: "operator-fix".to_owned(),
4557                handover: None,
4558            })
4559            .await;
4560        let agent_id = seat.agent.clone();
4561
4562        let mut fix = FixRecord {
4563            agent: agent_id,
4564            addressed: Vec::new(),
4565            rejected: Vec::new(),
4566            notes: String::new(),
4567            committed: false,
4568            failed: None,
4569            duration_ms: 0,
4570            continuation: None,
4571        };
4572        let mut final_seat = seat.clone();
4573        match out {
4574            AgentOutcome::Ok(o) => {
4575                fix.duration_ms = o.duration_ms;
4576                let parsed = verdict::extract_json::<FixReport>(&o.text);
4577                let incomplete_reason = match &parsed {
4578                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4579                        "the reply parsed, but it reported a command whose own CLI \
4580                         never confirmed an exit status"
4581                            .to_owned(),
4582                    ),
4583                    Ok(_) => None,
4584                    Err(e) => Some(e.to_string()),
4585                };
4586                match incomplete_reason {
4587                    None => {
4588                        let report = parsed.expect("checked Ok above");
4589                        fix.addressed = report.addressed;
4590                        fix.rejected = report.rejected;
4591                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4592                    }
4593                    Some(reason) => {
4594                        let (resumed_seat, resolved, failure, cont) = self
4595                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4596                            .await;
4597                        fix.duration_ms += cont.cumulative_wait_ms;
4598                        fix.continuation = Some(cont);
4599                        final_seat = resumed_seat;
4600                        match resolved {
4601                            Some(report) => {
4602                                fix.addressed = report.addressed;
4603                                fix.rejected = report.rejected;
4604                                fix.notes =
4605                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4606                            }
4607                            None => fix.failed = failure,
4608                        }
4609                    }
4610                }
4611            }
4612            AgentOutcome::Dropped(o) => {
4613                fix.duration_ms = o.duration_ms;
4614                let why = o
4615                    .dropped
4616                    .as_ref()
4617                    .map(|d| d.why.as_str())
4618                    .unwrap_or("the CLI ended the stream without delivering its answer");
4619                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4620            }
4621            AgentOutcome::Quota(o) => {
4622                self.state.quota.push(QuotaLoss {
4623                    seat: final_seat.key.clone(),
4624                    node: "fix".to_owned(),
4625                    at: Timestamp::now(),
4626                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4627                });
4628                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4629            }
4630            AgentOutcome::Failed(e) => fix.failed = Some(e),
4631        }
4632        if fix.continuation.is_none() {
4633            fix.continuation = Some(ContinuationRecord::not_needed());
4634        }
4635        self.state.seats.insert(final_seat.key.clone(), final_seat);
4636
4637        let rescue_message = format!(
4638            "magi: operator-selected fix ({}) (uncommitted work)",
4639            self.state.operator_fixes[request_index]
4640                .findings
4641                .iter()
4642                .map(|f| f.id.as_str())
4643                .collect::<Vec<_>>()
4644                .join(", ")
4645        );
4646        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4647            self.state.note_withheld("fix", &r.withheld);
4648        }
4649        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4650        fix.committed = after != head_at_request;
4651        git::worktree_remove(&self.state.repo, &fix_worktree)
4652            .await
4653            .ok();
4654
4655        self.state.event(
4656            "fix",
4657            match &fix.failed {
4658                Some(reason) => format!(
4659                    "operator fix: adoption report was lost ({reason}); {}",
4660                    if fix.committed {
4661                        "committed"
4662                    } else {
4663                        "NO new commit"
4664                    }
4665                ),
4666                None => format!(
4667                    "operator fix: {} addressed, {} rejected, {}",
4668                    fix.addressed.len(),
4669                    fix.rejected.len(),
4670                    if fix.committed {
4671                        "committed"
4672                    } else {
4673                        "NO new commit"
4674                    }
4675                ),
4676            },
4677        );
4678
4679        // Every selected finding gets an outcome — never left `Pending` once
4680        // the fixer's own turn is over. A report that never came back at all
4681        // marks every one of them `Unreported`, not silently "not addressed":
4682        // quota, a dropped stream, or an exhausted continuation are gaps in
4683        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4684        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4685        for f in &mut self.state.operator_fixes[request_index].findings {
4686            f.outcome = if fix.failed.is_some() {
4687                OperatorFixOutcome::Unreported
4688            } else if fix.addressed.contains(&f.id) {
4689                OperatorFixOutcome::Addressed
4690            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4691                OperatorFixOutcome::Rejected { why: r.why.clone() }
4692            } else {
4693                OperatorFixOutcome::Unreported
4694            };
4695        }
4696
4697        let committed = fix.committed;
4698        if committed {
4699            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4700        }
4701        self.state.operator_fixes[request_index].fix = Some(fix);
4702        // Saved again now that the fixer's own outcome is final, on top of
4703        // the save right after the request was first pushed above.
4704        self.state.save()?;
4705
4706        if committed {
4707            self.state.event(
4708                "fix",
4709                format!(
4710                    "operator fix committed {}; opening a follow-up review-only run",
4711                    short(&after)
4712                ),
4713            );
4714            // The operator asked for the fix, and the follow-up serves whatever
4715            // task the run it follows served.
4716            let origin =
4717                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4718            match Self::review(
4719                &self.state.repo,
4720                &winner.branch,
4721                self.state.config.clone(),
4722                origin,
4723            )
4724            .await
4725            {
4726                Ok(mut follow_up) => {
4727                    follow_up.state.event(
4728                        "start",
4729                        format!(
4730                            "requested by an operator fix on run {} for finding(s) {}",
4731                            self.state.id,
4732                            self.state.operator_fixes[request_index]
4733                                .findings
4734                                .iter()
4735                                .map(|f| f.id.as_str())
4736                                .collect::<Vec<_>>()
4737                                .join(", "),
4738                        ),
4739                    );
4740                    follow_up.state.save()?;
4741                    let follow_up_id = follow_up.state.id.clone();
4742                    // The follow-up is a run like any other: it belongs to the
4743                    // task of the run it follows, or to one filed for it.
4744                    let adopted = match crate::direct::adopt(
4745                        &follow_up.state,
4746                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4747                    ) {
4748                        Ok(a) => a,
4749                        Err(e) => {
4750                            // An ownerless run must not spend agent calls; it
4751                            // stays saved, and `magi run --resume` adopts it.
4752                            self.state.event(
4753                                "fix",
4754                                format!(
4755                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4756                                ),
4757                            );
4758                            self.state.save()?;
4759                            return Ok(());
4760                        }
4761                    };
4762                    let executed = follow_up.execute().await;
4763                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4764                    if let Some(a) = adopted {
4765                        a.finish(&follow_up.state, executed);
4766                    }
4767                    if let Some(e) = failure {
4768                        self.state.event(
4769                            "fix",
4770                            format!(
4771                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4772                            ),
4773                        );
4774                    }
4775                    self.state.operator_fixes[request_index].follow_up_review_run =
4776                        Some(follow_up_id);
4777                }
4778                Err(e) => {
4779                    self.state.event(
4780                        "fix",
4781                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4782                    );
4783                }
4784            }
4785            self.state.save()?;
4786        }
4787
4788        Ok(())
4789    }
4790
4791    // --------------------------------------------------------------- review
4792
4793    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
4794    /// names, in order, each at most once, moving on only when the call
4795    /// advances (an error, a quota hit or nothing usable - the same decision
4796    /// point `agent::chain_advances` is for the other chained roles).
4797    ///
4798    /// Each agent gets its own seat from `Runner::seat`: the same agent
4799    /// continues its conversation (the winner's own implementer seat when it
4800    /// is the winner's author, now that the competition is over), another
4801    /// takes a fresh one so the full prompt is sent again. A handover is
4802    /// recorded under `node`, which is what makes the fallback stick for the
4803    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
4804    /// is returned, so an exhausted chain reads exactly like a single failed
4805    /// fixer: one quota loss, the same wording, the same refund. An earlier
4806    /// attempt's edits are left in the tree and judged, with the final
4807    /// attempt's, by what git says afterwards.
4808    ///
4809    /// Returns the job that produced the outcome, for `continue_fix_report`.
4810    async fn ask_fixer(
4811        &mut self,
4812        winner: &Candidate,
4813        node: &'static str,
4814        round: Option<usize>,
4815        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4816    ) -> (SeatJob, SeatState, AgentOutcome) {
4817        let attempts = fixer::attempts(&self.state, &self.roles, winner);
4818        self.ask_fixer_among(attempts, node, round, None, build)
4819            .await
4820    }
4821
4822    /// [`Self::ask_fixer`] over an explicit attempt list. `restore` is a
4823    /// worktree and a commit: before the next agent is asked after one that
4824    /// did not satisfy the call, the worktree is put back on that commit, so
4825    /// a failed agent's partial edits are not carried into the next one.
4826    async fn ask_fixer_among(
4827        &mut self,
4828        attempts: Vec<(AgentSpec, String)>,
4829        node: &'static str,
4830        round: Option<usize>,
4831        restore: Option<(&Path, &str)>,
4832        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4833    ) -> (SeatJob, SeatState, AgentOutcome) {
4834        let run_id = self.state.id.clone();
4835        let prompts = self.state.config.prompts.clone();
4836        let cache = self.state.config.cache_dir();
4837        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4838        let mut last = None;
4839        for (i, (spec, key)) in attempts.into_iter().enumerate() {
4840            let seat = self.seat(&key, &spec.id);
4841            let mut job = build(spec.clone(), seat);
4842            if i > 0 {
4843                job.stem = format!("{}-{}", job.stem, spec.id);
4844            }
4845            let ctx = WaveCtx {
4846                carry_seats: false,
4847                run: &run_id,
4848                node,
4849                prompts: &prompts,
4850                cache: cache.as_deref(),
4851                round,
4852            };
4853            let (seat, out) =
4854                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4855            let advances = match &out {
4856                AgentOutcome::Ok(o) => agent::output_advances(o),
4857                _ => true,
4858            };
4859            if let Some(next) = ids.get(i + 1)
4860                && advances
4861            {
4862                self.state.seats.insert(seat.key.clone(), seat.clone());
4863                let class =
4864                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4865                let reason = match &out {
4866                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4867                    other => fail_reason(other),
4868                };
4869                record_handover(
4870                    &mut self.state,
4871                    node,
4872                    &seat.key,
4873                    &spec.id,
4874                    next,
4875                    &class,
4876                    &reason,
4877                );
4878                // A failed agent's edits must not reach the next one, and a
4879                // restore that did not work ends the chain: the call fails
4880                // and the caller's own (confirmed) restore decides.
4881                if let Some((wt, rev)) = restore
4882                    && let Err(e) = git::restore_to(wt, rev).await
4883                {
4884                    let why =
4885                        format!("could not restore the worktree before the next fixer: {e:#}");
4886                    tracing::warn!("{why}");
4887                    last = Some((job, seat, AgentOutcome::Failed(why)));
4888                    break;
4889                }
4890                continue;
4891            }
4892            last = Some((job, seat, out));
4893            break;
4894        }
4895        last.expect("the fixer chain always has an entry")
4896    }
4897
4898    async fn review_loop(&mut self) -> Result<()> {
4899        // A base that would not rebase is a person's decision, not a review
4900        // round: nothing here would change the answer, and reviewers and a
4901        // fixer would be spending real budget on a tree that cannot land
4902        // regardless of what they find.
4903        if self
4904            .state
4905            .base_sync
4906            .as_ref()
4907            .is_some_and(|s| s.conflict.is_some())
4908        {
4909            return Ok(());
4910        }
4911        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4912        // agent files with `magi task add` name the run that paid for it. The
4913        // prompt overlay is cloned alongside it because the waves borrow it
4914        // while `self` is mutably borrowed by the node's own bookkeeping.
4915        let run_id = self.state.id.clone();
4916        let prompts = self.state.config.prompts.clone();
4917        let Some(winner) = self.state.winner().cloned() else {
4918            return Ok(());
4919        };
4920        let max_rounds = self.state.config.graph.review_rounds;
4921        // A clean round, an exhausted round budget, or a stalled tree (see
4922        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4923        // they are recorded — recomputed here, not read off `status`, so a
4924        // reentry into a run that already stopped restates the identical
4925        // verdict instead of silently handing back whatever an earlier node
4926        // in this same walk clobbered `status` to (a solo-candidate
4927        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4928        // below runs an empty range once the budget is spent, and would
4929        // otherwise fall through without touching `status` at all.
4930        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4931            // A reentry after a crash between the last round's save and
4932            // `stop_reviewing` reaches the hand-off here, not there.
4933            if status == RunStatus::Gating {
4934                self.record_contested_handoff();
4935            }
4936            self.state.status = status;
4937            self.state.save()?;
4938            return Ok(());
4939        }
4940        self.state.status = RunStatus::Reviewing;
4941        // A last recorded round whose own verification never resolved
4942        // (`ResourceBlocked` — the shared build cache, not the patch) is
4943        // never a concluded round, whatever the round budget says: starting
4944        // a fresh round on top of it would spend a whole new reviewer wave
4945        // re-reading an unchanged patch instead of just retrying the one
4946        // check that actually needs it, and once the budget is spent the
4947        // loop below has nothing left to do at all (its range is empty).
4948        // Retry that check directly instead, exactly the same retry
4949        // `stop_reviewing` already does for its own catch-up case.
4950        if self
4951            .state
4952            .reviews
4953            .last()
4954            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4955        {
4956            let shell = self.state.config.shell();
4957            return self
4958                .stop_reviewing(
4959                    "the last round's own verification never resolved",
4960                    &shell,
4961                    &winner.worktree,
4962                )
4963                .await;
4964        }
4965
4966        let repo = self.state.repo.clone();
4967        let root = self.state.worktree_root();
4968        let language = self.state.config.graph.language.clone();
4969        let sessions = self.state.config.graph.sessions;
4970        let artifacts = agent::artifacts_dir(&self.state.dir());
4971        let base = self.landing_base();
4972        let base_short = short(&base);
4973        let reviewers = self.roles.reviewers.clone();
4974        let shell = self.state.config.shell();
4975
4976        for round in (self.state.reviews.len() + 1)..=max_rounds {
4977            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4978            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4979            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4980            // The prior round's own record, already persisted — never a
4981            // hand-carried variable of just its failing output: that is
4982            // exactly what let a round's e2e result drift out of sync with
4983            // which commit it was actually about (see `SCHEMA`'s doc for
4984            // schema 8). Judged against `head`, the commit reviewers are
4985            // about to look at now, so the summary always reads as "an
4986            // earlier head" here — this round's own patch has not been
4987            // checked yet.
4988            let prev_verification = self
4989                .state
4990                .reviews
4991                .last()
4992                .and_then(|r| r.verification_summary(&head));
4993
4994            // Each reviewer gets its own detached checkout of exactly this
4995            // commit: nobody can perturb the winner's tree, and the fixer can
4996            // keep working without racing a reviewer.
4997            let mut jobs = Vec::new();
4998            for (r, spec) in reviewers.iter().cloned().enumerate() {
4999                let wt = root.join(format!("review-{}", r + 1));
5000                // `clean -fdx` in a reset wipes setup's products, so every
5001                // round sets the seat up again.
5002                if wt.exists() {
5003                    git::reset_detached(&wt, &head).await?;
5004                } else {
5005                    git::worktree_add_detached(&repo, &wt, &head).await?;
5006                }
5007                self.setup_seat_worktree(&repo, &wt, false).await?;
5008                let seat_key = format!("review-{}", r + 1);
5009                // The seat starts the round on whoever answered it last, not
5010                // on the agent the spec names, so a failure is not re-paid.
5011                let spec = pick_start_spec(
5012                    &self.roles.reviewer_roster,
5013                    spec,
5014                    self.state.seat_history.get(&seat_key),
5015                );
5016                let seat = self.seat(&seat_key, &spec.id);
5017                jobs.push(SeatJob {
5018                    prompt: prompt::review(&prompt::ReviewCtx {
5019                        instruction: &self.state.instruction,
5020                        branch: &winner.branch,
5021                        base_short: &base_short,
5022                        stat: &stat,
5023                        patch: &patch,
5024                        verification: prev_verification.as_ref(),
5025                        reviewers: reviewers.len(),
5026                        round,
5027                        rounds: max_rounds,
5028                        // A review-only run has no rankings, so nothing
5029                        // competed for this patch and the reviewer is told so.
5030                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
5031                        lens: Lens::for_seat(r),
5032                        language: &language,
5033                    }),
5034                    spec,
5035                    seat,
5036                    cwd: wt,
5037                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5038                    allow_write: false,
5039                    sessions,
5040                    artifacts: artifacts.clone(),
5041                    stem: format!("review-{round}-{}", r + 1),
5042                    handover: None,
5043                });
5044            }
5045
5046            self.state.event(
5047                "review",
5048                format!(
5049                    "round {round}: {} reviewers on {}",
5050                    jobs.len(),
5051                    short(&head)
5052                ),
5053            );
5054            let mut quota_losses = Vec::new();
5055            let review_retries = self.state.config.graph.retries;
5056            let review_cache = self.state.config.cache_dir();
5057            let ctx = WaveCtx {
5058                carry_seats: true,
5059                run: &run_id,
5060                node: "review",
5061                prompts: &prompts,
5062                cache: review_cache.as_deref(),
5063                round: Some(round),
5064            };
5065            let results = ask_json_wave::<Review>(
5066                jobs,
5067                Arc::clone(&self.sem),
5068                review_retries,
5069                &self.roles.reviewer_roster,
5070                &ctx,
5071                &mut quota_losses,
5072                &mut self.state,
5073                &|_: &Review| Ok(()),
5074            )
5075            .await;
5076            // Counted before the move below: how many of *this* round's
5077            // reviewer seats were lost to their own rate limit, as opposed to
5078            // a crash, a timeout, or unparsable output — see `round_is_clean`.
5079            let round_quota_missing = quota_losses.len();
5080            self.state.quota.extend(quota_losses);
5081
5082            let mut records = Vec::new();
5083            let mut all_findings = Vec::new();
5084            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
5085                let agent_id = seat.agent.clone();
5086                self.state.seats.insert(seat.key.clone(), seat);
5087                let mut record = ReviewRecord {
5088                    reviewer: r + 1,
5089                    agent: agent_id,
5090                    summary: String::new(),
5091                    findings: Vec::new(),
5092                    vote: None,
5093                    failed: None,
5094                    duration_ms: 0,
5095                    // Set for both outcomes: `failed: Some(_)` with
5096                    // `attempts > 0` is a seat every retry still lost, not a
5097                    // recovered one — only `failed: None` with `attempts > 0`
5098                    // reads as "answered after a nudge" (see this field's own
5099                    // doc).
5100                    attempts,
5101                };
5102                match res {
5103                    Ok((review, out)) => {
5104                        // Sanitized here, at the point every other piece of
5105                        // agent prose in this file is (candidate summaries,
5106                        // deliberation turns, vote reasons): a reviewer's own
5107                        // words are the one thing about it that could name
5108                        // it, and reconsideration below broadcasts this same
5109                        // summary and these same findings to every other
5110                        // seat on the panel.
5111                        record.summary =
5112                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
5113                        record.vote = Some(review.vote);
5114                        record.duration_ms = out.duration_ms;
5115                        for (n, mut f) in review.findings.into_iter().enumerate() {
5116                            // ids are magi's, never the agent's: the fixer's
5117                            // adoption report is keyed by them.
5118                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
5119                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
5120                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
5121                            // `file` is agent-supplied prose too, never
5122                            // checked against the real tree — the same
5123                            // exposure `title`/`detail` above have, just in
5124                            // a field easy to forget because it looks like a
5125                            // path rather than free text.
5126                            f.file = f
5127                                .file
5128                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
5129                            all_findings.push(f.clone());
5130                            record.findings.push(f);
5131                        }
5132                        self.state.event(
5133                            "review",
5134                            format!(
5135                                "round {round}: reviewer {} voted {} with {} finding(s)",
5136                                r + 1,
5137                                review.vote.label(),
5138                                record.findings.len()
5139                            ),
5140                        );
5141                    }
5142                    Err(e) => {
5143                        record.failed = Some(e.to_string());
5144                        self.state.event(
5145                            "review",
5146                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
5147                        );
5148                    }
5149                }
5150                records.push(record);
5151            }
5152
5153            // Tally the round's votes and, if they split, spend the one
5154            // round of reconsideration the split -> deliberate -> revote
5155            // shape `judge`/`vote` use for the panel, sized down to what a
5156            // read-only review round can afford: one round, and a revote
5157            // rather than an argument, because the panel already wrote its
5158            // reasoning down as findings the first time around.
5159            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
5160            let vote_split =
5161                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
5162            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
5163            if vote_split {
5164                self.state.event(
5165                    "review",
5166                    format!(
5167                        "round {round}: votes split ({}) — one round of reconsideration",
5168                        initial_votes
5169                            .iter()
5170                            .map(|v| v.label())
5171                            .collect::<Vec<_>>()
5172                            .join(", ")
5173                    ),
5174                );
5175                // Seats read every seat's findings and votes, still numbered
5176                // and never named — the same anonymity `review` itself keeps.
5177                let panel: Vec<ReviewSeatReport<'_>> = records
5178                    .iter()
5179                    .filter_map(|r| {
5180                        r.vote.map(|vote| ReviewSeatReport {
5181                            reviewer: r.reviewer,
5182                            vote,
5183                            summary: &r.summary,
5184                            findings: &r.findings,
5185                        })
5186                    })
5187                    .collect();
5188
5189                let mut jobs = Vec::new();
5190                let mut seats_at = Vec::new();
5191                for (r, spec) in reviewers.iter().cloned().enumerate() {
5192                    // A seat with no initial vote has nothing to reconsider
5193                    // from and stays absent, the same as it stayed absent
5194                    // from `panel` above.
5195                    if records[r].vote.is_none() {
5196                        continue;
5197                    }
5198                    let wt = root.join(format!("review-{}", r + 1));
5199                    let seat_key = format!("review-{}", r + 1);
5200                    let spec = self.occupant(&seat_key, spec);
5201                    let seat = self.seat(&seat_key, &spec.id);
5202                    // A seat with no live session has already forgotten the
5203                    // initial review's prompt — restate the patch it is
5204                    // voting on, the same as `deliberate`/`vote` do for a
5205                    // judge in the same position.
5206                    // The panel already carries this seat's own review and
5207                    // vote, so restating the patch makes the prompt whole for
5208                    // a seat handed to another agent.
5209                    let build = |with_patch: bool| {
5210                        prompt::review_reconsider(&ReviewReconsiderCtx {
5211                            instruction: &self.state.instruction,
5212                            reviewer: r + 1,
5213                            lens: Lens::for_seat(r),
5214                            panel: &panel,
5215                            patch: with_patch.then_some(ReviewPatch {
5216                                branch: &winner.branch,
5217                                base_short: &base_short,
5218                                stat: &stat,
5219                                patch: &patch,
5220                            }),
5221                            round,
5222                            rounds: max_rounds,
5223                            language: &language,
5224                        })
5225                    };
5226                    let full = build(true);
5227                    let prompt = if has_context(&spec, &seat, sessions) {
5228                        build(false)
5229                    } else {
5230                        full.clone()
5231                    };
5232                    jobs.push(SeatJob {
5233                        prompt,
5234                        spec,
5235                        seat,
5236                        cwd: wt,
5237                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
5238                        allow_write: false,
5239                        sessions,
5240                        artifacts: artifacts.clone(),
5241                        stem: format!("review-{round}-reconsider-{}", r + 1),
5242                        handover: Some(full),
5243                    });
5244                    seats_at.push(r);
5245                }
5246
5247                let mut recon_quota_losses = Vec::new();
5248                let recon_cache = self.state.config.cache_dir();
5249                let recon_ctx = WaveCtx {
5250                    carry_seats: true,
5251                    run: &run_id,
5252                    node: "review",
5253                    prompts: &prompts,
5254                    cache: recon_cache.as_deref(),
5255                    round: Some(round),
5256                };
5257                let recon_results = ask_json_wave::<ReviewRevote>(
5258                    jobs,
5259                    Arc::clone(&self.sem),
5260                    review_retries,
5261                    &self.roles.reviewer_roster,
5262                    &recon_ctx,
5263                    &mut recon_quota_losses,
5264                    &mut self.state,
5265                    &|_: &ReviewRevote| Ok(()),
5266                )
5267                .await;
5268                self.state.quota.extend(recon_quota_losses);
5269
5270                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
5271                    let agent_id = seat.agent.clone();
5272                    self.state.seats.insert(seat.key.clone(), seat);
5273                    let mut rec = ReviewRevoteRecord {
5274                        reviewer: r + 1,
5275                        agent: agent_id,
5276                        vote: None,
5277                        reason: String::new(),
5278                        failed: None,
5279                    };
5280                    match res {
5281                        Ok((rv, _)) => {
5282                            rec.vote = Some(rv.vote);
5283                            rec.reason =
5284                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
5285                            self.state.event(
5286                                "review",
5287                                format!(
5288                                    "round {round}: reviewer {} revoted {}",
5289                                    r + 1,
5290                                    rv.vote.label()
5291                                ),
5292                            );
5293                        }
5294                        Err(e) => {
5295                            rec.failed = Some(e.to_string());
5296                            self.state.event(
5297                                "review",
5298                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
5299                            );
5300                        }
5301                    }
5302                    reconsideration.push(rec);
5303                }
5304            } else if initial_votes.len() > 1 {
5305                self.state.event(
5306                    "review",
5307                    format!(
5308                        "round {round}: votes agreed ({}) — no reconsideration",
5309                        initial_votes[0].label()
5310                    ),
5311                );
5312            }
5313
5314            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
5315            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5316            // A round that already has a blocking finding and a round left to
5317            // try is going back to the fixer no matter what `verify.e2e`
5318            // says, so running it first only spends the loop's slowest step
5319            // (minutes, for a Rust repo's full test suite) on a head about
5320            // to be rewritten. Deferred, never skipped: `verify.e2e` still
5321            // runs once a round has no blocking findings left (see
5322            // `round_is_clean`, which a deferred — empty — `e2e` can never
5323            // satisfy since `blocking` is nonzero whenever this branch is
5324            // taken), and `stop_reviewing` forces a real run before it will
5325            // ever read a deferred round as green.
5326            let defer_e2e =
5327                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
5328            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
5329                let reason =
5330                    format!("{blocking} blocking finding(s) already required a fix this round");
5331                self.state.event(
5332                    "verify",
5333                    format!(
5334                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
5335                         {}); it will run once a round has none left",
5336                        short(&head)
5337                    ),
5338                );
5339                (Vec::new(), false, true, Some(reason))
5340            } else {
5341                let e2e_commands = self.state.config.verify.e2e.clone();
5342                let cache_dir = self.state.config.cache_dir();
5343                let context = format!("round {round}");
5344                let (e2e, verify_retried) = with_cache_lease(
5345                    &mut self.state,
5346                    cache_dir.as_deref(),
5347                    "e2e",
5348                    "e2e",
5349                    &winner.worktree,
5350                    &head,
5351                    verify_timeout,
5352                    &context,
5353                    |state, budget| {
5354                        let shell = shell.clone();
5355                        let e2e_commands = e2e_commands.clone();
5356                        let worktree = winner.worktree.clone();
5357                        let context = context.clone();
5358                        async move {
5359                            run_e2e_with_retry(
5360                                state,
5361                                &shell,
5362                                &e2e_commands,
5363                                &worktree,
5364                                budget,
5365                                &context,
5366                            )
5367                            .await
5368                        }
5369                    },
5370                )
5371                .await;
5372                (e2e, verify_retried, false, None)
5373            };
5374
5375            let expected = records.len();
5376            let answered = records.iter().filter(|r| r.failed.is_none()).count();
5377            let incomplete = answered < expected;
5378            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5379            let policy = self.state.config.graph.incomplete_review;
5380            let clean = round_is_clean(
5381                blocking,
5382                e2e_ok,
5383                answered,
5384                expected,
5385                round_quota_missing,
5386                policy,
5387            );
5388
5389            let mut round_record = ReviewRound {
5390                round,
5391                head: head.clone(),
5392                verified_head: None,
5393                verified_at: None,
5394                reviews: records,
5395                e2e,
5396                verify_retried,
5397                e2e_deferred,
5398                e2e_defer_reason,
5399                fix: None,
5400                blocking,
5401                answered,
5402                expected,
5403                clean,
5404                progressed: false,
5405                vote_split,
5406                reconsideration,
5407                verdict: None,
5408            };
5409            // The final vote per seat is its revote where reconsideration
5410            // ran and answered, its initial vote otherwise — the same
5411            // fallback `tally` uses for a judge whose private vote failed.
5412            round_record.verdict = ReviewVote::worst(
5413                round_record
5414                    .final_votes()
5415                    .into_iter()
5416                    .map(|(_, _, vote)| vote),
5417            );
5418            // Which commit and when magi actually attempted to check —
5419            // known the moment a command was dispatched against `head`,
5420            // whether or not it finished: a resource-blocked attempt still
5421            // targeted a specific commit at a specific time, and leaving
5422            // that unrecorded is exactly what made `verification_summary`
5423            // report a fresh attempt as "commit unknown ... recorded before
5424            // this was tracked", indistinguishable from a genuinely old,
5425            // untracked record. Only a deferred or unconfigured round never
5426            // ran at all and has nothing to record — see
5427            // `ReviewRound::verified_head`'s own doc.
5428            if !matches!(
5429                round_record.e2e_status(),
5430                E2eStatus::Deferred | E2eStatus::NotConfigured
5431            ) {
5432                round_record.verified_head = Some(head.clone());
5433                round_record.verified_at = Some(Timestamp::now());
5434            }
5435            let this_round_verification = round_record.verification_summary(&head);
5436
5437            if incomplete {
5438                let missing: Vec<String> = round_record
5439                    .reviews
5440                    .iter()
5441                    .filter(|r| r.failed.is_some())
5442                    .map(|r| format!("review-{}", r.reviewer))
5443                    .collect();
5444                self.state.event(
5445                    "review",
5446                    format!(
5447                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5448                        missing.join(", ")
5449                    ),
5450                );
5451            }
5452
5453            if clean {
5454                self.state.event(
5455                    "review",
5456                    if incomplete && policy == IncompleteReviewPolicy::Warn {
5457                        format!(
5458                            "round {round}: clean (warn policy, incomplete panel) — no \
5459                             blocking findings from the seats that answered, verification green"
5460                        )
5461                    } else if incomplete {
5462                        format!(
5463                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5464                             quorum) — no blocking findings from the seats that answered, \
5465                             verification green",
5466                            expected - answered
5467                        )
5468                    } else {
5469                        format!("round {round}: clean — no blocking findings, verification green")
5470                    },
5471                );
5472                self.state.reviews.push(round_record);
5473                self.state.status = RunStatus::Gating;
5474                self.state.save()?;
5475                return Ok(());
5476            }
5477
5478            // Nothing was raised and verification passed, but not every seat
5479            // answered and `round_is_clean` still refused to call it clean —
5480            // either a seat is missing for a reason other than its own quota
5481            // (a crash, a timeout, unparsable output — worth another try), or
5482            // every seat that could have answered lost its quota and nobody
5483            // is left to decide on: re-review rather than send the fixer
5484            // after a round with nothing to fix.
5485            if incomplete && blocking == 0 && e2e_ok {
5486                self.state.reviews.push(round_record);
5487                self.state.save()?;
5488                if round == max_rounds {
5489                    self.state.status = RunStatus::Blocked;
5490                    self.state.event(
5491                        "review",
5492                        format!(
5493                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5494                             refusing to call it clean",
5495                            expected - answered
5496                        ),
5497                    );
5498                    return Ok(());
5499                }
5500                continue;
5501            }
5502
5503            // Nothing for the fixer to act on (`blocking == 0`) and the only
5504            // reason this round is not clean is that magi itself never got
5505            // a command to run — the shared build cache, not the patch (see
5506            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5507            // the fixer would invite a change to appease contention that has
5508            // nothing to do with the diff, and would leave this attempt
5509            // sitting in the next round's prompt as if it were about an
5510            // earlier, superseded commit rather than what it actually is:
5511            // the same head, still waiting to be checked. Wait for it the
5512            // same way the final round's own contention is already handled,
5513            // whatever round this happens to be.
5514            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5515                self.state.reviews.push(round_record);
5516                return self
5517                    .stop_reviewing(
5518                        "the round's own verification could not run",
5519                        &shell,
5520                        &winner.worktree,
5521                    )
5522                    .await;
5523            }
5524
5525            if round == max_rounds {
5526                self.state.reviews.push(round_record);
5527                return self
5528                    .stop_reviewing(
5529                        &format!(
5530                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5531                        ),
5532                        &shell,
5533                        &winner.worktree,
5534                    )
5535                    .await;
5536            }
5537
5538            // Fix. The winner's own implementer seat continues its conversation:
5539            // the competition is over, so context is pure benefit now.
5540            let blocking_findings: Vec<_> = all_findings
5541                .iter()
5542                .filter(|f| f.severity.blocks())
5543                .cloned()
5544                .collect();
5545            let fix_prompt = prompt::fix(
5546                &self.state.instruction,
5547                &blocking_findings,
5548                this_round_verification.as_ref(),
5549                round,
5550                max_rounds,
5551                &language,
5552            );
5553            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5554            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5555            let (job, seat, out) = self
5556                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5557                    prompt: fix_prompt.clone(),
5558                    spec,
5559                    seat,
5560                    cwd: winner.worktree.clone(),
5561                    timeout,
5562                    allow_write: true,
5563                    sessions,
5564                    artifacts: artifacts.clone(),
5565                    stem: format!("fix-{round}"),
5566                    handover: None,
5567                })
5568                .await;
5569            let agent_id = seat.agent.clone();
5570
5571            let mut fix = FixRecord {
5572                agent: agent_id,
5573                addressed: Vec::new(),
5574                rejected: Vec::new(),
5575                notes: String::new(),
5576                committed: false,
5577                failed: None,
5578                duration_ms: 0,
5579                continuation: None,
5580            };
5581            let mut continuation = ContinuationRecord::not_needed();
5582            let mut final_seat = seat.clone();
5583            match out {
5584                AgentOutcome::Ok(o) => {
5585                    fix.duration_ms = o.duration_ms;
5586                    let parsed = verdict::extract_json::<FixReport>(&o.text);
5587                    // A parsed report standing next to a command this same
5588                    // reply's own CLI never confirmed the exit status of is
5589                    // not a resolved answer — the identical `CommandEvidence`
5590                    // `state.jobs` renders, read here instead of only on
5591                    // display, per the completion judgment and the shown
5592                    // record needing to agree.
5593                    let incomplete_reason = match &parsed {
5594                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5595                            "the reply parsed, but it reported a command whose own CLI never \
5596                             confirmed an exit status"
5597                                .to_owned(),
5598                        ),
5599                        Ok(_) => None,
5600                        Err(e) => Some(e.to_string()),
5601                    };
5602                    match incomplete_reason {
5603                        None => {
5604                            let report = parsed.expect("checked Ok above");
5605                            fix.addressed = report.addressed;
5606                            fix.rejected = report.rejected;
5607                            fix.notes =
5608                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
5609                        }
5610                        Some(reason) => {
5611                            let (resumed_seat, resolved, failure, cont) = self
5612                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5613                                .await;
5614                            fix.duration_ms += cont.cumulative_wait_ms;
5615                            continuation = cont;
5616                            final_seat = resumed_seat;
5617                            match resolved {
5618                                Some(report) => {
5619                                    fix.addressed = report.addressed;
5620                                    fix.rejected = report.rejected;
5621                                    fix.notes = blind::sanitize_prose(
5622                                        &report.notes,
5623                                        &self.state.config.blind,
5624                                    );
5625                                }
5626                                None => fix.failed = failure,
5627                            }
5628                        }
5629                    }
5630                }
5631                // The CLI's raw error JSON is not a fix report to parse.
5632                AgentOutcome::Dropped(o) => {
5633                    fix.duration_ms = o.duration_ms;
5634                    let why = o
5635                        .dropped
5636                        .as_ref()
5637                        .map(|d| d.why.as_str())
5638                        .unwrap_or("the CLI ended the stream without delivering its answer");
5639                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5640                }
5641                AgentOutcome::Quota(o) => {
5642                    self.state.quota.push(QuotaLoss {
5643                        seat: final_seat.key.clone(),
5644                        node: "fix".to_owned(),
5645                        at: Timestamp::now(),
5646                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5647                    });
5648                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5649                }
5650                AgentOutcome::Failed(e) => fix.failed = Some(e),
5651            }
5652            fix.continuation = Some(continuation);
5653            self.state.seats.insert(final_seat.key.clone(), final_seat);
5654            if let Ok(r) = git::rescue_commit(
5655                &winner.worktree,
5656                &format!("magi: review round {round} fixes (uncommitted work)"),
5657            )
5658            .await
5659            {
5660                self.state.note_withheld("fix", &r.withheld);
5661            }
5662            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5663            fix.committed = after != before;
5664            // Judged by what `git` says moved against base, never by the
5665            // fixer's own `addressed`/`rejected` count — see
5666            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5667            // `patch` snapshot above: swallowing this error would default
5668            // `diff_after` to empty, which almost always differs from a
5669            // non-empty `patch` and reads as "progressed" — exactly backwards
5670            // for a `git` failure the stagnation check cannot see through.
5671            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5672            let progressed = diff_after != patch;
5673            let commit_note = if fix.committed {
5674                "committed"
5675            } else {
5676                "NO new commit"
5677            };
5678            let tree_note = if progressed {
5679                "changed vs base"
5680            } else {
5681                "unchanged vs base"
5682            };
5683            self.state.event(
5684                "fix",
5685                match &fix.failed {
5686                    // Distinct on purpose from "0 addressed, 0 rejected": the
5687                    // fixer's own diff still landed (blocking counts do keep
5688                    // falling round over round), only its adoption report did
5689                    // not come back, so this must never read like every
5690                    // finding was reviewed and declined.
5691                    Some(reason) => {
5692                        format!(
5693                            "round {round}: fixer's adoption report was lost ({reason}); \
5694                             {commit_note}, tree {tree_note}"
5695                        )
5696                    }
5697                    None => format!(
5698                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5699                         {tree_note}{}",
5700                        fix.addressed.len(),
5701                        fix.rejected.len(),
5702                        if continuation.outcome == ContinuationOutcome::Resumed {
5703                            format!(
5704                                " (adoption report recovered after {} continuation(s))",
5705                                continuation.attempts
5706                            )
5707                        } else {
5708                            String::new()
5709                        },
5710                    ),
5711                },
5712            );
5713            round_record.fix = Some(fix);
5714            round_record.progressed = progressed;
5715            self.state.reviews.push(round_record);
5716            self.state.save()?;
5717
5718            // The fixer's own report never came back this round, even after
5719            // `continue_fix_report`'s own budget was spent on it — not an
5720            // ordinary "no report" (dropped stream, quota, plain failure),
5721            // which already reads that way and is left to the existing round
5722            // budget. Stopping here, rather than opening another round, is
5723            // what keeps a next reviewer/fixer wave from ever being
5724            // dispatched onto `winner.worktree` while whatever the seat's
5725            // last call may still have running there is unaccounted for: no
5726            // process liveness check exists (and none is being added — see
5727            // AGENTS.md/this task's own scope), so the only way to honour
5728            // "nothing starts before a valid report returns" is to not start
5729            // anything further on this worktree from this run at all.
5730            if matches!(
5731                continuation.outcome,
5732                ContinuationOutcome::Exhausted
5733                    | ContinuationOutcome::QuotaLost
5734                    | ContinuationOutcome::NoSession
5735            ) {
5736                return self
5737                    .stop_reviewing(
5738                        "the fixer's adoption report never came back, even after resuming its \
5739                         own seat; refusing to start another round against the same worktree \
5740                         while that is unresolved",
5741                        &shell,
5742                        &winner.worktree,
5743                    )
5744                    .await;
5745            }
5746
5747            let streak = self
5748                .state
5749                .reviews
5750                .iter()
5751                .rev()
5752                .take_while(|r| !r.progressed)
5753                .count();
5754            if streak >= STAGNANT_LIMIT {
5755                return self
5756                    .stop_reviewing(
5757                        &format!(
5758                            "the tree has not moved against base for {streak} round(s) in a row"
5759                        ),
5760                        &shell,
5761                        &winner.worktree,
5762                    )
5763                    .await;
5764            }
5765        }
5766        Ok(())
5767    }
5768
5769    /// Decide, from the last recorded round's own verification, whether
5770    /// stopping the review loop is a hand-off or a genuine block.
5771    ///
5772    /// Called once the loop has given up trying — the round budget is spent,
5773    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5774    /// findings still open, never while a round is still clean or the
5775    /// incomplete-panel case handled inline above. Gate and e2e are facts
5776    /// about the tree; a lingering review finding is an opinion, and this
5777    /// workload's own `magi stats` puts reviewer precision low enough
5778    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5779    /// must not by itself stand between a green, verified change and the
5780    /// human who decides what to do with it. A red e2e is not an opinion, so
5781    /// that case still blocks, with the failing command and a tail of its
5782    /// output recorded here rather than left in `run.json` for someone to go
5783    /// find.
5784    ///
5785    /// A round that deferred its own e2e (see [`Config::graph`]'s
5786    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5787    /// only because nothing ran, and treating an empty list as a passing one
5788    /// here is exactly the "deferred painted green" bug this function exists
5789    /// to not have. When the last round's own verification never resolved —
5790    /// deferred on purpose, or a real attempt the shared build cache blocked
5791    /// — this makes (or retries) the real run, on the actual worktree this
5792    /// loop is about to stop touching, before deciding anything. A
5793    /// resource-blocked attempt is likewise never read as either green or
5794    /// red: it is evidence about the machine, not the patch (see
5795    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5796    /// blocked cache leaves this call without deciding rather than guessing
5797    /// — the caller retries on a later reentry.
5798    /// Record, once, that the review loop handed off over a blocking finding
5799    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5800    /// `land` asks the owner even with `land_approval` off. Called from every
5801    /// path that concludes `Gating`; a reentry keeps the first record.
5802    fn record_contested_handoff(&mut self) {
5803        if self.state.contested_handoff.is_some() {
5804            return;
5805        }
5806        let Some(contested) = self
5807            .state
5808            .reviews
5809            .last()
5810            .and_then(ReviewRound::contested_handoff)
5811        else {
5812            return;
5813        };
5814        self.state.event(
5815            "review",
5816            format!(
5817                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5818                 wait for the owner's approval",
5819                contested.findings.len(),
5820                contested.rejecters.len()
5821            ),
5822        );
5823        self.state.contested_handoff = Some(contested);
5824    }
5825
5826    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5827        let round_idx = self.state.reviews.len() - 1;
5828        // A deferred round and a resource-blocked one are the same shape
5829        // here: neither has a real result yet, and both get one more
5830        // attempt. Read off `e2e_status` — the single source for this —
5831        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5832        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5833        // still retries instead of being read as a settled result the
5834        // instant it stops being empty.
5835        let needs_catchup_run = matches!(
5836            self.state.reviews[round_idx].e2e_status(),
5837            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5838        );
5839        if needs_catchup_run {
5840            let round = self.state.reviews[round_idx].round;
5841            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5842            let commands = self.state.config.verify.e2e.clone();
5843            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5844            let cache_dir = self.state.config.cache_dir();
5845            let context = format!(
5846                "round {round}: verification unresolved, catching up before the final decision"
5847            );
5848            let (outcomes, verify_retried) = with_cache_lease(
5849                &mut self.state,
5850                cache_dir.as_deref(),
5851                "e2e",
5852                "e2e",
5853                worktree,
5854                &attempted_head,
5855                timeout,
5856                &context,
5857                |state, budget| {
5858                    let shell = shell.to_vec();
5859                    let commands = commands.clone();
5860                    let context = context.clone();
5861                    async move {
5862                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5863                            .await
5864                    }
5865                },
5866            )
5867            .await;
5868            let last = &mut self.state.reviews[round_idx];
5869            last.e2e = outcomes;
5870            last.verify_retried = verify_retried;
5871            // Always the commit and time this attempt actually targeted,
5872            // whether or not it happens to equal the reviewed `head` and
5873            // whether or not a command finished — see
5874            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5875            // attempt is recorded too, so a later reader sees "attempted
5876            // again at T2" rather than silence.
5877            last.verified_head = Some(attempted_head);
5878            last.verified_at = Some(Timestamp::now());
5879            if verify_inconclusive(&last.e2e) {
5880                // Still not a real result: `e2e_deferred` is left exactly
5881                // as it was, so `needs_catchup_run` above reads
5882                // `ResourceBlocked` (via `e2e_status`, which checks
5883                // `resource_blocked` before `e2e_deferred`) and retries
5884                // again on the next reentry, rather than recording
5885                // contention as a red e2e and blocking the run on it.
5886                self.state.save()?;
5887                return Ok(());
5888            }
5889            last.e2e_deferred = false;
5890        }
5891        let last = &self.state.reviews[round_idx];
5892        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5893
5894        match last.e2e_status() {
5895            E2eStatus::Failed => {
5896                let red: Vec<String> = last
5897                    .e2e
5898                    .iter()
5899                    .filter(|o| !o.ok())
5900                    .map(|o| {
5901                        format!(
5902                            "`{}` -> {:?}\n{}",
5903                            o.command,
5904                            o.code,
5905                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5906                        )
5907                    })
5908                    .collect();
5909                self.state
5910                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5911                self.state.status = RunStatus::Blocked;
5912            }
5913            // `needs_catchup_run` above already retried once this call; if
5914            // it is still blocked, this is magi's own admission it could
5915            // not get a command to run, never a verdict on the patch — the
5916            // run is left exactly where a later reentry can retry again.
5917            E2eStatus::ResourceBlocked => {
5918                self.state.event(
5919                    "review",
5920                    format!(
5921                        "{why}; e2e could not run (shared build cache unavailable); not \
5922                         deciding yet"
5923                    ),
5924                );
5925            }
5926            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5927                self.state.event(
5928                    "review",
5929                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5930                );
5931                self.record_contested_handoff();
5932                self.state.status = RunStatus::Gating;
5933            }
5934        }
5935        self.state.save()?;
5936        Ok(())
5937    }
5938
5939    // ----------------------------------------------------------------- gate
5940
5941    async fn gate(&mut self) -> Result<()> {
5942        // Judged by the review record itself, not by `status`: a solo
5943        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5944        // reentry (see `judge`), and trusting it here is exactly how a run
5945        // that exhausted its review budget got gated and merged a second
5946        // time around. `review_conclusion` recomputes the review loop's own
5947        // verdict from the round records themselves — `Gating` for a clean
5948        // round or a hand-off (see `stop_reviewing`), anything else means the
5949        // loop is still going or genuinely blocked.
5950        // A base the winner could not be replayed onto is a decision, not a
5951        // round: there is no landing tree to gate. Read as its own record for
5952        // the same reason the review verdict is.
5953        if self.state.status == RunStatus::Failed
5954            || self
5955                .state
5956                .base_sync
5957                .as_ref()
5958                .is_some_and(|s| s.conflict.is_some())
5959            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5960                != Some(RunStatus::Gating)
5961        {
5962            return Ok(());
5963        }
5964        if self.state.gate_ran {
5965            // `review_loop` derives its conclusion from the clean review
5966            // record on every reentry and therefore puts a completed run back
5967            // in `Gating`. A recorded gate is a stronger, terminal fact:
5968            // retain its original command output (or lack of any, for a repo
5969            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5970            // doc) and restore `Blocked` on a real failure rather than
5971            // pretending the command is still running or running it a second
5972            // time. `gate_ran == false` remains the only shape — unattempted,
5973            // or a resource-blocked retry — that may still need to execute a
5974            // command.
5975            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5976                self.state.status = RunStatus::Blocked;
5977                self.state.save()?;
5978            }
5979            return Ok(());
5980        }
5981        let Some(winner) = self.state.winner().cloned() else {
5982            return Ok(());
5983        };
5984        self.state.status = RunStatus::Gating;
5985        let mut outcomes = self.run_gate(&winner).await?;
5986        loop {
5987            // A resource-blocked outcome means the gate command never actually
5988            // ran - the shared build cache could not be acquired or confirmed
5989            // fresh in time - which is evidence about the machine, not about
5990            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5991            // Recording it as a red gate would mark a run `Blocked` on nothing
5992            // but contention magi has already logged; leaving `self.state.gate`
5993            // empty and `self.state.gate_ran` false instead keeps the shape
5994            // this function already treats as "still needs to run" (see the
5995            // early-return above), so the next call retries the command
5996            // rather than concluding anything.
5997            if verify_inconclusive(&outcomes) {
5998                self.state.save()?;
5999                return Ok(());
6000            }
6001            if outcomes.iter().all(CommandOutcome::ok) {
6002                break;
6003            }
6004            match self.gate_fix_round(&winner, &outcomes).await? {
6005                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
6006                GateFix::Stop => break,
6007                GateFix::Defer => {
6008                    self.state.save()?;
6009                    return Ok(());
6010                }
6011            }
6012        }
6013        let passed = outcomes.iter().all(CommandOutcome::ok);
6014        self.state.gate = outcomes;
6015        self.state.gate_ran = true;
6016        if !passed {
6017            self.state.status = RunStatus::Blocked;
6018            let spent = self.state.gate_fixes.len();
6019            self.state.event(
6020                "gate",
6021                if spent == 0 {
6022                    "gate failed; not merging".to_owned()
6023                } else {
6024                    format!("gate failed after {spent} gate-fix round(s); not merging")
6025                },
6026            );
6027        }
6028        self.state.save()?;
6029        Ok(())
6030    }
6031
6032    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
6033    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
6034    /// after review is clean and never on a candidate awaiting judging.
6035    ///
6036    /// Never fails the run: a non-zero exit or timeout is a warning and a
6037    /// recorded outcome, and the gate remains the single arbiter. Nothing
6038    /// configured means nothing happens - no event, no commit. `commit_all`
6039    /// commits any leftover change under the neutral identity and returns
6040    /// `false` when the tree is clean, so no empty commit is ever made.
6041    async fn run_pre_gate(&mut self, winner: &Candidate) {
6042        let commands = self.state.config.verify.pre_gate.clone();
6043        if commands.is_empty() {
6044            return;
6045        }
6046        let shell = self.state.config.shell();
6047        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6048        let (outcomes, _) = run_commands(
6049            &mut self.state,
6050            "pre_gate",
6051            "pre_gate",
6052            0,
6053            &shell,
6054            &commands,
6055            &winner.worktree,
6056            timeout,
6057        )
6058        .await;
6059        for o in &outcomes {
6060            if !o.ok() {
6061                tracing::warn!(
6062                    "pre_gate `{}` failed ({:?}); the gate decides",
6063                    o.command,
6064                    o.code
6065                );
6066            }
6067            self.state.event(
6068                "pre_gate",
6069                format!(
6070                    "`{}` -> {}",
6071                    o.command,
6072                    if o.ok() {
6073                        "pass".to_owned()
6074                    } else {
6075                        format!(
6076                            "FAIL ({:?})\n{}",
6077                            o.code,
6078                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6079                        )
6080                    }
6081                ),
6082            );
6083        }
6084        self.state.pre_gate = outcomes;
6085        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
6086            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
6087                Ok(head) => {
6088                    self.state
6089                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
6090                    self.state.pre_gate_commit = Some(head);
6091                }
6092                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
6093            },
6094            Ok(false) => {}
6095            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
6096        }
6097        if let Err(e) = self.state.save() {
6098            tracing::warn!("could not persist the pre_gate record: {e:#}");
6099        }
6100    }
6101
6102    /// Run `verify.gate` once against the winner's current tree, logging one
6103    /// event per command. Empty when nothing is configured.
6104    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
6105        self.run_pre_gate(winner).await;
6106        let shell = self.state.config.shell();
6107        let gate_commands = self.state.config.verify.gate.clone();
6108        // Zero commands has nothing to run and nothing that could touch the
6109        // shared build cache, so it never needs a lease: `Config::cache_dir`
6110        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
6111        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
6112        // otherwise queue behind an unrelated run's lease and come back
6113        // resource-blocked - `gate_ran` would stay false on nothing but
6114        // cache contention, for a step that had nothing to check in the
6115        // first place.
6116        let outcomes = if gate_commands.is_empty() {
6117            Vec::new()
6118        } else {
6119            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6120            let cache_dir = self.state.config.cache_dir();
6121            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
6122            let (outcomes, _) = with_cache_lease(
6123                &mut self.state,
6124                cache_dir.as_deref(),
6125                "gate",
6126                "gate",
6127                &winner.worktree,
6128                &head,
6129                timeout,
6130                "final gate",
6131                |state, budget| {
6132                    let shell = shell.clone();
6133                    let gate_commands = gate_commands.clone();
6134                    let worktree = winner.worktree.clone();
6135                    async move {
6136                        let (outcomes, timed_out_pids) = run_commands(
6137                            state,
6138                            "gate",
6139                            "gate",
6140                            0,
6141                            &shell,
6142                            &gate_commands,
6143                            &worktree,
6144                            budget,
6145                        )
6146                        .await;
6147                        (outcomes, false, timed_out_pids)
6148                    }
6149                },
6150            )
6151            .await;
6152            outcomes
6153        };
6154        if outcomes.is_empty() {
6155            // Nothing configured to check — distinct from every other
6156            // silence in this run's event log, since an empty `gate` alone
6157            // no longer says whether the gate ran at all (see
6158            // `RunState::gate_ran`'s own doc).
6159            self.state.event(
6160                "gate",
6161                "no gate commands configured; nothing to check, passing",
6162            );
6163        }
6164        for o in &outcomes {
6165            self.state.event(
6166                "gate",
6167                format!(
6168                    "`{}` -> {}",
6169                    o.command,
6170                    if o.ok() {
6171                        "pass".to_owned()
6172                    } else {
6173                        format!(
6174                            "FAIL ({:?})\n{}",
6175                            o.code,
6176                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
6177                        )
6178                    }
6179                ),
6180            );
6181        }
6182        Ok(outcomes)
6183    }
6184
6185    /// One bounded fix round for a failing gate.
6186    ///
6187    /// The fixer is told the failure came from the gate itself, not from a
6188    /// reviewer, and is shown the failed commands, their exit codes and a tail
6189    /// of their output - whatever `[verify].gate` holds, nothing here knows
6190    /// what those commands run. Only a normal non-zero exit that printed
6191    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
6192    /// command or a full disk says nothing about the code, and a fixer sent
6193    /// after it can only appease the machine. The round is judged by what git
6194    /// says moved, never by the fixer's own report, and `verify.e2e` runs
6195    /// again before the gate does, so a fix cannot trade a green gate for a
6196    /// red e2e unnoticed.
6197    async fn gate_fix_round(
6198        &mut self,
6199        winner: &Candidate,
6200        outcomes: &[CommandOutcome],
6201    ) -> Result<GateFix> {
6202        let cap = self.state.config.graph.gate_fix_rounds;
6203        let spent = self.state.gate_fixes.len();
6204        if spent >= cap {
6205            if cap > 0 {
6206                self.state.event(
6207                    "gate",
6208                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
6209                );
6210            }
6211            return Ok(GateFix::Stop);
6212        }
6213        if !gate_fixable(outcomes) {
6214            self.state.event(
6215                "gate",
6216                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
6217                 command or similar); not spending a fix round on it",
6218            );
6219            return Ok(GateFix::Stop);
6220        }
6221        let min_free = self.state.config.disk.min_free_bytes;
6222        if min_free > 0 {
6223            match crate::disk::free_bytes(&winner.worktree) {
6224                Ok(free) if crate::disk::enough_space(free, min_free) => {}
6225                Ok(free) => {
6226                    self.state.event(
6227                        "gate",
6228                        format!(
6229                            "only {free} bytes free ({min_free} required by `[disk] \
6230                             min_free_bytes`); not spending a fix round on a failure the disk \
6231                             may explain"
6232                        ),
6233                    );
6234                    return Ok(GateFix::Stop);
6235                }
6236                Err(e) => {
6237                    self.state.event(
6238                        "gate",
6239                        format!("free disk space could not be measured ({e:#}); no fix round"),
6240                    );
6241                    return Ok(GateFix::Stop);
6242                }
6243            }
6244        }
6245
6246        let attempt = spent + 1;
6247        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
6248        let base = self.landing_base();
6249        let fix_prompt = prompt::gate_fix(
6250            &self.state.instruction,
6251            &failed,
6252            attempt,
6253            cap,
6254            &self.state.config.graph.language,
6255        );
6256        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
6257        let sessions = self.state.config.graph.sessions;
6258        let artifacts = agent::artifacts_dir(&self.state.dir());
6259        self.state.event(
6260            "gate",
6261            format!("gate failed; gate-fix round {attempt} of {cap}"),
6262        );
6263        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
6264        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
6265        let (_, seat, out) = self
6266            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
6267                prompt: fix_prompt.clone(),
6268                spec,
6269                seat,
6270                cwd: winner.worktree.clone(),
6271                timeout,
6272                allow_write: true,
6273                sessions,
6274                artifacts: artifacts.clone(),
6275                stem: format!("gate-fix-{attempt}"),
6276                handover: None,
6277            })
6278            .await;
6279        let mut record = GateFixRecord {
6280            agent: seat.agent.clone(),
6281            failed,
6282            notes: String::new(),
6283            committed: false,
6284            error: None,
6285        };
6286        match out {
6287            AgentOutcome::Ok(o) => {
6288                // A missing report is not a failed fix: the round is judged
6289                // by the tree below, and the report only carries prose.
6290                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
6291                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
6292                }
6293            }
6294            AgentOutcome::Dropped(_) => {
6295                record.error = Some("the CLI dropped the stream".to_owned());
6296            }
6297            AgentOutcome::Quota(o) => {
6298                self.state.quota.push(QuotaLoss {
6299                    seat: seat.key.clone(),
6300                    node: "gate-fix".to_owned(),
6301                    at: Timestamp::now(),
6302                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
6303                });
6304                record.error = Some("rate limited (quota); fixer could not run".to_owned());
6305            }
6306            AgentOutcome::Failed(e) => record.error = Some(e),
6307        }
6308        self.state.seats.insert(seat.key.clone(), seat);
6309        if let Ok(r) = git::rescue_commit(
6310            &winner.worktree,
6311            &format!("magi: gate fix {attempt} (uncommitted work)"),
6312        )
6313        .await
6314        {
6315            self.state.note_withheld("gate-fix", &r.withheld);
6316        }
6317        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
6318        record.committed = after != before;
6319        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
6320        let note = record.error.clone();
6321        self.state.gate_fixes.push(record);
6322        self.state.save()?;
6323        if !changed {
6324            self.state.event(
6325                "gate",
6326                match note {
6327                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
6328                    None => format!("gate-fix round {attempt}: the tree did not change"),
6329                },
6330            );
6331            return Ok(GateFix::Stop);
6332        }
6333        self.state.event(
6334            "gate",
6335            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
6336        );
6337
6338        let commands = self.state.config.verify.e2e.clone();
6339        if !commands.is_empty() {
6340            let shell = self.state.config.shell();
6341            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
6342            let cache_dir = self.state.config.cache_dir();
6343            let context = format!("gate-fix round {attempt}");
6344            let (e2e, _) = with_cache_lease(
6345                &mut self.state,
6346                cache_dir.as_deref(),
6347                "e2e",
6348                "e2e",
6349                &winner.worktree,
6350                &after,
6351                timeout,
6352                &context,
6353                |state, budget| {
6354                    let shell = shell.clone();
6355                    let commands = commands.clone();
6356                    let context = context.clone();
6357                    let worktree = winner.worktree.clone();
6358                    async move {
6359                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
6360                            .await
6361                    }
6362                },
6363            )
6364            .await;
6365            if verify_inconclusive(&e2e) {
6366                return Ok(GateFix::Defer);
6367            }
6368            if e2e.iter().any(|o| !o.ok()) {
6369                self.state.event(
6370                    "gate",
6371                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6372                );
6373                return Ok(GateFix::Stop);
6374            }
6375        }
6376        Ok(GateFix::Retry)
6377    }
6378
6379    // ---------------------------------------------------------------- merge
6380
6381    /// One read-only rewrite by the summary's author; a text the gate still
6382    /// withholds goes to the owner (see `github_text`) and the fixed fallback
6383    /// applies on their word or on silence. `None` means the run is parked on
6384    /// that question and nothing may be pushed or opened yet.
6385    async fn guarded_pr_message(
6386        &mut self,
6387        winner: &Candidate,
6388        facts: Option<&BranchFacts>,
6389        posting: bool,
6390    ) -> Result<Option<PrMessage>> {
6391        let mut pr = pr_message_raw(&self.state, winner.label, facts);
6392        if !posting {
6393            let identity = crate::scrub::Identity::current();
6394            return Ok(Some(PrMessage {
6395                title: crate::scrub::scrub(&pr.title, &identity),
6396                body: crate::scrub::scrub(&pr.body, &identity),
6397            }));
6398        }
6399        // This very text was already rejected and asked about: no second
6400        // judge call, rewrite or question, just carry the record on.
6401        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6402        if let Some(known) = self
6403            .state
6404            .github_text
6405            .clone()
6406            .filter(|g| g.fingerprint == raw_fp)
6407        {
6408            return self.settle_withheld(winner, &pr, known).await;
6409        }
6410        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6411        let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6412        let identity = crate::scrub::Identity::current();
6413        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6414            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6415            && !violations.contains(&crate::github_text::Violation::SensitiveData)
6416        {
6417            violations.push(crate::github_text::Violation::SensitiveData);
6418        }
6419        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
6420        // only a language violation starts one; sensitive data alone goes
6421        // straight to the owner, with the redacted text as the fallback.
6422        let has_language = violations
6423            .iter()
6424            .any(|v| *v != crate::github_text::Violation::SensitiveData);
6425        if self.state.config.graph.github_text_guard && !violations.is_empty() {
6426            self.state.event(
6427                "github-text",
6428                if has_language {
6429                    format!("description rejected: {violations:?}; requesting one rewrite")
6430                } else {
6431                    format!("description rejected: {violations:?}; asking the owner")
6432                },
6433            );
6434            let mut rewritten = false;
6435            let mut rejected_rewrite = None;
6436            if has_language
6437                && let Some(spec) = self
6438                    .state
6439                    .config
6440                    .agents
6441                    .iter()
6442                    .find(|a| a.id == winner.agent)
6443                    .cloned()
6444            {
6445                let key = format!("impl-{}", winner.label);
6446                let seat = self.seat(&key, &spec.id);
6447                let prompt = format!(
6448                    "Rewrite only the following pull request description. The posting gate reported {violations:?}. Write English prose and remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
6449                    pr.title, pr.body
6450                );
6451                let job = SeatJob {
6452                    spec,
6453                    seat,
6454                    cwd: winner.worktree.clone(),
6455                    prompt,
6456                    timeout: retry_budget(
6457                        Duration::from_secs(self.state.config.graph.timeout_implement),
6458                        true,
6459                    ),
6460                    allow_write: false,
6461                    sessions: self.state.config.graph.sessions,
6462                    artifacts: agent::artifacts_dir(&self.state.dir()),
6463                    stem: "github-text-rewrite".to_owned(),
6464                    handover: None,
6465                };
6466                let prompts = self.state.config.prompts.clone();
6467                let cache = self.state.config.cache_dir();
6468                let run = self.state.id.clone();
6469                let ctx = WaveCtx {
6470                    run: &run,
6471                    node: "github-text",
6472                    prompts: &prompts,
6473                    cache: cache.as_deref(),
6474                    round: None,
6475                    carry_seats: false,
6476                };
6477                let (seat, outcome) =
6478                    run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6479                self.state.seats.insert(seat.key.clone(), seat);
6480                if let AgentOutcome::Ok(output) = outcome
6481                    && let Some(title) = summary_title(&output.text)
6482                {
6483                    // Judge the redacted candidate that will actually be posted.
6484                    // Sensitive data alone must not discard a useful rewrite.
6485                    let raw_body = summary_without_title(&output.text);
6486                    let clean_title = crate::scrub::scrub(&title, &identity);
6487                    let mut body = crate::scrub::scrub(&raw_body, &identity);
6488                    let redacted = clean_title != title || body != raw_body;
6489                    let title = clean_title;
6490                    let marker = format!("magi:run/{}", self.state.id);
6491                    if !body.trim().is_empty() && !body.contains(&marker) {
6492                        body.push_str(&format!("\n\n{marker}"));
6493                    }
6494                    let rewrite = PrMessage {
6495                        title: title.clone(),
6496                        body: body.clone(),
6497                    };
6498                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6499                    let mut failed = crate::github_text::check_with(&title, &body, redecision);
6500                    if (!crate::github_text::shareable(&title)
6501                        || !crate::github_text::shareable(&body))
6502                        && !failed.contains(&crate::github_text::Violation::SensitiveData)
6503                    {
6504                        failed.push(crate::github_text::Violation::SensitiveData);
6505                    }
6506                    if !title.trim().is_empty() && !body.trim().is_empty() && !failed.is_empty() {
6507                        rejected_rewrite = Some(crate::run::GithubTextRewrite {
6508                            title: title.clone(),
6509                            body: body.clone(),
6510                            failed: failed
6511                                .iter()
6512                                .map(|v| crate::github_text::category(*v).to_owned())
6513                                .collect(),
6514                        });
6515                    }
6516                    if !title.trim().is_empty() && !body.trim().is_empty() && failed.is_empty() {
6517                        decision = redecision;
6518                        pr = PrMessage { title, body };
6519                        rewritten = true;
6520                        self.state
6521                            .event("github-text", "description rewrite passed");
6522                        if redacted {
6523                            self.state.event(
6524                                "github-text",
6525                                "sensitive data removed from description rewrite",
6526                            );
6527                        }
6528                    }
6529                }
6530            }
6531            if !rewritten {
6532                self.state.event(
6533                    "github-text",
6534                    "description rewrite unavailable or rejected; asking the owner",
6535                );
6536                if let Some(w) =
6537                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6538                {
6539                    // A different text than the one asked about before: that
6540                    // question no longer describes anything.
6541                    self.retire_github_text_question("the withheld text changed");
6542                    let record = crate::run::GithubTextAsk {
6543                        rewrite: rejected_rewrite,
6544                        fingerprint: raw_fp,
6545                        title: w.title,
6546                        body: w.body,
6547                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6548                        question: None,
6549                        asks: 0,
6550                        resolved: false,
6551                        chosen_title: None,
6552                        choice: None,
6553                    };
6554                    return self.settle_withheld(winner, &pr, record).await;
6555                }
6556            }
6557        }
6558        let (title, body) =
6559            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6560        Ok(Some(PrMessage { title, body }))
6561    }
6562
6563    /// Abandon the standing posting-gate question, if any.
6564    fn retire_github_text_question(&mut self, why: &str) {
6565        let Some(id) = self
6566            .state
6567            .github_text
6568            .as_ref()
6569            .and_then(|g| g.question.clone())
6570        else {
6571            return;
6572        };
6573        let _ = ask::Questions::open().update(&id, |q| {
6574            q.abandon(why);
6575            Ok(())
6576        });
6577    }
6578
6579    /// Carry a withheld text to its end: file the question, park while it is
6580    /// open, and on an answer, silence or timeout pick the text to post. The
6581    /// decision is saved before it is used, so it applies once.
6582    async fn settle_withheld(
6583        &mut self,
6584        winner: &Candidate,
6585        pr: &PrMessage,
6586        mut g: crate::run::GithubTextAsk,
6587    ) -> Result<Option<PrMessage>> {
6588        use crate::github_text as gt;
6589        let store = ask::Questions::open();
6590        let timeout = self.state.config.graph.answer_timeout;
6591        if !g.resolved {
6592            let mut standing = g
6593                .question
6594                .as_ref()
6595                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6596            if let Some(q) = standing.as_ref()
6597                && q.status.open()
6598                && gt::expired(q, timeout)
6599            {
6600                let why = format!("no answer within {}s of asking", timeout.max(1));
6601                standing = store
6602                    .update(&q.id, |q| {
6603                        q.abandon(&why);
6604                        Ok(())
6605                    })
6606                    .ok()
6607                    .map(|(q, ())| q);
6608            }
6609            let mut ask_again: Option<Option<Vec<String>>> = None;
6610            match standing {
6611                // Recorded but never written (a stop between the two saves),
6612                // or removed: ask again without spending the retry.
6613                None if g.asks <= 1 => {
6614                    g.asks = 0;
6615                    ask_again = Some(None);
6616                }
6617                None => {
6618                    g.resolved = true;
6619                    g.chosen_title = None;
6620                    g.choice = Some(GithubTextChoice::Fallback);
6621                }
6622                Some(q) if q.status.open() => {
6623                    self.park_on_github_text(&g)?;
6624                    return Ok(None);
6625                }
6626                Some(q) => match gt::read_reply(&q) {
6627                    gt::Reply::Fallback => {
6628                        g.resolved = true;
6629                        g.chosen_title = None;
6630                        g.choice = Some(GithubTextChoice::Fallback);
6631                    }
6632                    gt::Reply::Original => {
6633                        g.resolved = true;
6634                        g.chosen_title = None;
6635                        g.choice = Some(GithubTextChoice::Original);
6636                    }
6637                    gt::Reply::Rewrite => {
6638                        g.resolved = true;
6639                        g.chosen_title = None;
6640                        g.choice = Some(if g.rewrite.is_some() {
6641                            GithubTextChoice::Rewrite
6642                        } else {
6643                            GithubTextChoice::Fallback
6644                        });
6645                    }
6646                    gt::Reply::Title(text) => {
6647                        let first = text
6648                            .lines()
6649                            .map(str::trim)
6650                            .find(|l| !l.is_empty())
6651                            .unwrap_or("");
6652                        let decision = if first.is_empty() {
6653                            None
6654                        } else {
6655                            let candidate = PrMessage {
6656                                title: first.to_owned(),
6657                                body: String::new(),
6658                            };
6659                            self.judge_pr_language(&winner.worktree, &candidate).await
6660                        };
6661                        match gt::vet_title(&text, decision) {
6662                            Ok(title) => {
6663                                g.resolved = true;
6664                                g.chosen_title = Some(title);
6665                                g.choice = Some(GithubTextChoice::MyTitle);
6666                            }
6667                            Err(cats) if g.asks < 2 => {
6668                                ask_again = Some(Some(
6669                                    cats.into_iter().map(str::to_owned).collect::<Vec<_>>(),
6670                                ))
6671                            }
6672                            Err(_) => {
6673                                g.resolved = true;
6674                                g.chosen_title = None;
6675                                g.choice = Some(GithubTextChoice::Fallback);
6676                            }
6677                        }
6678                    }
6679                },
6680            }
6681            if let Some(rejected) = ask_again {
6682                // A retry (after a rejected replacement title) is still about
6683                // the original text: the same ORIGINAL / FALLBACK comparison.
6684                let w = gt::Withheld {
6685                    title: g.title,
6686                    body: g.body,
6687                    categories: g.categories.clone(),
6688                };
6689                // Named by the original text's fingerprint alone, so a resume
6690                // rewrites the same file and the detail stays identical.
6691                let artifact = match crate::run::write_artifact(
6692                    &self.state,
6693                    &gt::artifact_name(&gt::fingerprint(&pr.title, &pr.body)),
6694                    &gt::artifact_text(&w, &pr.title, &pr.body, &self.state.id, g.rewrite.as_ref()),
6695                ) {
6696                    Ok(p) => Some(p),
6697                    Err(e) => {
6698                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
6699                        None
6700                    }
6701                };
6702                let mut q = ask::Question::new(
6703                    self.state.id.clone(),
6704                    gt::ASK_NODE.to_owned(),
6705                    gt::ASK_SEAT.to_owned(),
6706                    gt::question_summary(&self.state.config.graph.language, &w),
6707                    gt::question_detail(
6708                        &w,
6709                        &pr.title,
6710                        &pr.body,
6711                        &self.state.id,
6712                        rejected.as_deref(),
6713                        artifact.as_deref(),
6714                        g.rewrite.as_ref(),
6715                    ),
6716                    gt::question_choices(&w, g.rewrite.as_ref()),
6717                );
6718                q.answer_timeout = timeout;
6719                // Recorded before it is written: a stop in between leaves a
6720                // record naming a question the resume then files again.
6721                g.question = Some(q.id.clone());
6722                g.asks += 1;
6723                self.state.github_text = Some(g.clone());
6724                self.state.event(
6725                    "github-text",
6726                    format!("asking the owner about the withheld text ({})", q.short()),
6727                );
6728                self.state.save()?;
6729                store
6730                    .put(&mut q)
6731                    .context("file the posting-gate question")?;
6732                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6733                    tracing::warn!(
6734                        "could not notify about posting-gate question {}: {e:#}",
6735                        q.short()
6736                    );
6737                }
6738                self.park_on_github_text(&g)?;
6739                return Ok(None);
6740            }
6741        }
6742        let resolved = g.chosen_title.clone();
6743        let original = g.choice == Some(GithubTextChoice::Original);
6744        // Field names only: the event log never records the text.
6745        self.state.event(
6746            "github-text",
6747            if original {
6748                format!(
6749                    "posting the original text for: {}",
6750                    match (g.title, g.body) {
6751                        (true, true) => "title, description",
6752                        (true, false) => "title",
6753                        _ => "description",
6754                    }
6755                )
6756            } else if g.choice == Some(GithubTextChoice::Rewrite) && g.rewrite.is_some() {
6757                "posting the saved rewrite title and description".to_owned()
6758            } else if resolved.is_some() {
6759                "posting the owner's replacement title".to_owned()
6760            } else {
6761                "posting the neutral text for the withheld fields".to_owned()
6762            },
6763        );
6764        self.state.github_text = Some(g.clone());
6765        self.state.save()?;
6766        if g.choice == Some(GithubTextChoice::Rewrite)
6767            && let Some(r) = g.rewrite.as_ref()
6768        {
6769            return Ok(Some(PrMessage {
6770                title: r.title.clone(),
6771                body: r.body.clone(),
6772            }));
6773        }
6774        let w = gt::Withheld {
6775            title: g.title,
6776            body: g.body,
6777            categories: g.categories.clone(),
6778        };
6779        let (mut title, body) = gt::fallback_text(&w, &pr.title, &pr.body, &self.state.id);
6780        if let Some(t) = resolved {
6781            title = t;
6782        }
6783        // Every withheld field is now neutral or vetted English.
6784        let english = gt::LanguageDecision {
6785            title_english: true,
6786            body_english: true,
6787            body_complete: true,
6788        };
6789        let (mut title, mut body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6790        if original {
6791            // The owner's explicit choice overrides the gate for the fields it
6792            // was made for; the others keep what the gate produced.
6793            if g.title {
6794                title = pr.title.clone();
6795            }
6796            if g.body {
6797                body = pr.body.clone();
6798            }
6799        }
6800        Ok(Some(PrMessage { title, body }))
6801    }
6802
6803    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6804        self.state.github_text = Some(g.clone());
6805        self.state.parked = true;
6806        self.state.event(
6807            "github-text",
6808            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6809        );
6810        self.state.save()
6811    }
6812
6813    /// Ask `[roles] language_judge` about this text and record which source
6814    /// decided; the text itself is never recorded.
6815    async fn judge_pr_language(
6816        &mut self,
6817        cwd: &Path,
6818        pr: &PrMessage,
6819    ) -> Option<crate::github_text::LanguageDecision> {
6820        self.state.config.roles.language_judge.as_ref()?;
6821        let decision =
6822            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6823        self.state.event(
6824            "github-text",
6825            if decision.is_some() {
6826                "language decided by the language judge"
6827            } else {
6828                "language judge unavailable; using built-in heuristics"
6829            },
6830        );
6831        decision
6832    }
6833
6834    async fn merge(&mut self) -> Result<()> {
6835        // Same reasoning as `gate`: ask the review and gate records directly
6836        // rather than `status`, which a solo-candidate `judge`/`deliberate`
6837        // skip can rewrite on reentry to something that no longer says
6838        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
6839        // so a hand-off (open findings, green verification) reaches merge
6840        // exactly like a genuinely clean round does.
6841        //
6842        // A run resumed mid-`land` never reaches here at all: `execute`
6843        // recognises `RunStatus::Landing` before it even calls `prep`, and
6844        // routes straight to `run_land` instead. That has to happen a level
6845        // up from this function, not with a check in here, because
6846        // `review_loop`'s own status recomputation (see its doc) runs
6847        // *before* `merge` on every reentry and would otherwise overwrite
6848        // the `Landing` marker with `Gating` before this node ever saw it.
6849        if self
6850            .state
6851            .base_sync
6852            .as_ref()
6853            .is_some_and(|s| s.conflict.is_some())
6854            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6855                != Some(RunStatus::Gating)
6856            // `gate_ran == false` is not "passed" - `gate` leaves it false
6857            // both before it has ever run and when its last attempt was
6858            // resource-blocked (see `Runner::gate`'s own doc), and neither is
6859            // permission to merge on nothing but the review record. Only a
6860            // gate that actually ran - zero commands configured and
6861            // vacuously passed, or one or more that all exited 0 - may
6862            // proceed; `RunState::gate_status` is the single place that
6863            // reading is computed.
6864            || !self.state.gate_status().ok()
6865        {
6866            return Ok(());
6867        }
6868        // This node's own record, not `status`: `status == Ready` is not
6869        // unique to the harmless `MergeMode::None` path this line was
6870        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
6871        // run's PR was closed without merging — and on that run `mode` is
6872        // still `Pr`, so a reentry that fell through here would push and
6873        // open a second pull request. `self.state.merge` is set exactly once
6874        // this node (or `land`) has already produced a verdict, under every
6875        // mode, which is what "already done" actually means here.
6876        if self.state.merge.is_some() && !self.state.pr_publication_pending {
6877            return Ok(());
6878        }
6879        let Some(winner) = self.state.winner().cloned() else {
6880            return Ok(());
6881        };
6882        let repo = self.state.repo.clone();
6883        let base = self.state.base_branch.clone();
6884        let mode = self.state.config.merge.mode;
6885        let style = self.state.config.merge.style;
6886        let facts = if is_review_run(&self.state) {
6887            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6888            let start = review_base(
6889                &repo,
6890                &self.state.config.merge.remote,
6891                &base,
6892                &self.state.base_commit,
6893                &winner.branch,
6894            )
6895            .await;
6896            branch_facts(&repo, &start, &winner.branch).await
6897        } else {
6898            None
6899        };
6900        // `None` when the base could not be freshly read: then an adopted
6901        // pull request's title is left alone.
6902        let leaked = if is_review_run(&self.state) {
6903            leaked_subjects(&self.state, &winner.branch).await
6904        } else {
6905            Some(Vec::new())
6906        };
6907        // `None`: parked on the owner's word about a withheld title or body.
6908        // Nothing has been pushed or opened, and `merge` stays unrecorded so
6909        // the resume arrives here again.
6910        let Some(pr) = self
6911            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6912            .await?
6913        else {
6914            return Ok(());
6915        };
6916        let message = pr.commit_message();
6917
6918        let outcome = match mode {
6919            MergeMode::None => MergeOutcome {
6920                mode,
6921                ok: true,
6922                detail: manual_merge_command(style, &repo, &winner.branch, &message),
6923                empty: false,
6924            },
6925            MergeMode::Pr | MergeMode::Local
6926                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6927            {
6928                MergeOutcome {
6929                    mode,
6930                    ok: false,
6931                    detail: empty_candidate_detail(&self.state, &base),
6932                    empty: true,
6933                }
6934            }
6935            MergeMode::Local => {
6936                let on = git::current_branch(&repo).await?;
6937                if on.as_deref() != Some(base.as_str()) {
6938                    MergeOutcome {
6939                        mode,
6940                        ok: false,
6941                        detail: format!(
6942                            "{} has {} checked out, not the base branch {base}",
6943                            repo.display(),
6944                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
6945                        ),
6946                        empty: false,
6947                    }
6948                } else if !git::is_clean(&repo).await? {
6949                    MergeOutcome {
6950                        mode,
6951                        ok: false,
6952                        detail: format!("{} is dirty; refusing to merge", repo.display()),
6953                        empty: false,
6954                    }
6955                } else {
6956                    let out = match style {
6957                        MergeStyle::Merge => {
6958                            git::merge_no_ff(&repo, &winner.branch, &message).await?
6959                        }
6960                        MergeStyle::Squash => {
6961                            git::merge_squash(&repo, &winner.branch, &message).await?
6962                        }
6963                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6964                    };
6965                    MergeOutcome {
6966                        mode,
6967                        ok: out.ok(),
6968                        detail: if out.ok() { out.stdout } else { out.stderr },
6969                        empty: false,
6970                    }
6971                }
6972            }
6973            MergeMode::Pr if self.state.task_pr_branch().is_some() => {
6974                self.state.pr_publication_pending = true;
6975                self.state.save()?;
6976                let out =
6977                    crate::pr_head::publish(&mut self.state, &winner, &pr.title, &pr.body).await;
6978                self.state.pr_publication_pending = out.is_err();
6979                MergeOutcome {
6980                    mode,
6981                    ok: out.is_ok(),
6982                    detail: match out {
6983                        Ok(url) => url,
6984                        Err(e) => format!("{e:#}"),
6985                    },
6986                    empty: false,
6987                }
6988            }
6989            MergeMode::Pr => {
6990                let remote = self.state.config.merge.remote.clone();
6991                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6992                if !pushed.ok() {
6993                    MergeOutcome {
6994                        mode,
6995                        ok: false,
6996                        detail: pushed.stderr,
6997                        empty: false,
6998                    }
6999                } else {
7000                    // A retry or resume of a run whose branch already has an
7001                    // open pull request adopts it rather than failing on a
7002                    // duplicate. Only this winner branch into this base:
7003                    // `branch_for` derives the name from the run id, so a
7004                    // different run's pull request never matches.
7005                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
7006                    let out = match pr_merge_plan(found) {
7007                        PrPlan::Create => {
7008                            gh_pr_create(
7009                                &winner.worktree,
7010                                &base,
7011                                &winner.branch,
7012                                &pr.title,
7013                                &pr.body,
7014                            )
7015                            .await
7016                        }
7017                        PrPlan::Adopt { url, title } => {
7018                            self.state
7019                                .event("merge", format!("Pr: adopted open pull request {url}"));
7020                            if self.state.github_text.as_ref().is_some_and(|g| {
7021                                g.resolved
7022                                    && g.choice == Some(GithubTextChoice::Rewrite)
7023                                    && g.rewrite.is_some()
7024                            }) {
7025                                // The owner chose the entire pair, even if the
7026                                // existing title already matches or was edited.
7027                                land::set_pr_text_verbatim(
7028                                    &winner.worktree,
7029                                    &url,
7030                                    &pr.title,
7031                                    &pr.body,
7032                                )
7033                                .await
7034                                .map(|()| url)
7035                            } else {
7036                                if title != pr.title
7037                                    && (!is_review_run(&self.state)
7038                                        || leaked
7039                                            .as_deref()
7040                                            .is_some_and(|l| should_retitle(&title, &pr.title, l)))
7041                                    && let Err(e) =
7042                                        if self.state.github_text.as_ref().is_some_and(|g| {
7043                                            g.resolved
7044                                                && g.title
7045                                                && g.choice == Some(GithubTextChoice::Original)
7046                                        }) {
7047                                            // The owner chose to post this title unmodified.
7048                                            land::set_pr_title_verbatim(
7049                                                &winner.worktree,
7050                                                &url,
7051                                                &pr.title,
7052                                            )
7053                                            .await
7054                                        } else {
7055                                            land::set_pr_title(
7056                                                &mut self.state,
7057                                                &winner.worktree,
7058                                                &url,
7059                                                &pr.title,
7060                                            )
7061                                            .await
7062                                        }
7063                                {
7064                                    tracing::warn!("could not refresh title of {url}: {e:#}");
7065                                    self.state
7066                                        .event("merge", format!("Pr: title refresh failed: {e:#}"));
7067                                }
7068                                Ok(url)
7069                            }
7070                        }
7071                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
7072                    };
7073                    match out {
7074                        Ok(url) => MergeOutcome {
7075                            mode,
7076                            ok: true,
7077                            detail: url,
7078                            empty: false,
7079                        },
7080                        Err(e) => MergeOutcome {
7081                            mode,
7082                            ok: false,
7083                            detail: e.to_string(),
7084                            empty: false,
7085                        },
7086                    }
7087                }
7088            }
7089        };
7090
7091        self.state.status = match (mode, outcome.ok) {
7092            (MergeMode::None, _) => RunStatus::Ready,
7093            (_, true) => RunStatus::Merged,
7094            (_, false) => RunStatus::Blocked,
7095        };
7096        self.state.event(
7097            "merge",
7098            format!(
7099                "{:?}: {}",
7100                mode,
7101                outcome.detail.lines().next().unwrap_or("")
7102            ),
7103        );
7104        self.state.merge = Some(outcome);
7105        self.state.save()?;
7106
7107        // The PR is open and the run would historically stop here, leaving the
7108        // operator to watch checks, feed review comments back to a fixer, and
7109        // merge. That was done by hand six times in one session before this
7110        // existed. Opt-in, because merging is the one irreversible thing magi
7111        // can do to a repository.
7112        if self.state.config.graph.land
7113            && mode == MergeMode::Pr
7114            && self.state.status == RunStatus::Merged
7115        {
7116            self.run_land().await?;
7117        }
7118        // `run_land` may have left `status` at `Landing` - still waiting on
7119        // CI or the owner's approval, not actually settled - so this has to
7120        // read whatever `status` ended up as here, not the `Merged` this
7121        // function set a few lines up.
7122        self.settle_questions();
7123        Ok(())
7124    }
7125
7126    /// Enter `land`.
7127    ///
7128    /// Shared between a fresh run's first pass through [`Runner::merge`] and
7129    /// a resumed run's re-entry. `land::land` itself is what serialises the
7130    /// two git-mutating moments inside the loop — the rebase push and
7131    /// `gh pr merge` — per repository (see its own doc); nothing here needs
7132    /// to hold a lock across the whole call, and doing so would serialise
7133    /// this run's CI wait against a *different* run's land-approval resume
7134    /// in the same repository, which is exactly the "must not wait on
7135    /// another task" property the daemon's slot-freeing exists to give.
7136    async fn run_land(&mut self) -> Result<()> {
7137        crate::pr_head::ensure_owner(&self.state)?;
7138        let url = self
7139            .state
7140            .merge
7141            .as_ref()
7142            .map(|m| m.detail.clone())
7143            .unwrap_or_default();
7144        let url = url.lines().next().unwrap_or("").trim().to_owned();
7145        if !url.starts_with("http") {
7146            return Ok(());
7147        }
7148        // A land failure is not a lost run: the work is on a branch and the
7149        // pull request is open, which is exactly where a human takes over.
7150        match land::land(&mut self.state, &url).await {
7151            Ok(pr) if self.state.parked => {
7152                // `land` already saved the parked marker; nothing here
7153                // overrides `status` back to a terminal value while an
7154                // approval is still outstanding.
7155                let _ = pr;
7156            }
7157            Ok(pr) => {
7158                self.state.status = match pr.state {
7159                    land::PrLifecycle::Merged => RunStatus::Merged,
7160                    _ => RunStatus::Blocked,
7161                };
7162                // Downstream of a confirmed merge only - see
7163                // `bump::should_release_bump`'s own doc for why this one
7164                // check covers all three of `land`'s success paths.
7165                // Best-effort: the run already landed, so a failure here
7166                // (the decision call, `gh`, `cargo`) is recorded and never
7167                // turns a landed run into a failed one.
7168                if bump::should_release_bump(self.state.status)
7169                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
7170                {
7171                    // The event is the run's own record. Not-eligible cases
7172                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
7173                    // `Err` is a bump that was tried and failed:
7174                    // `after_merge` itself raises the operator notice for
7175                    // that, whether or not a release PR exists yet.
7176                    self.state
7177                        .event("bump", format!("release bump skipped: {e:#}"));
7178                }
7179                // Independent of the bump, and best-effort in the same way:
7180                // findings the merge left open become follow-up tasks.
7181                if self.state.status == RunStatus::Merged {
7182                    crate::followup::after_merge(&mut self.state, &pr.url).await;
7183                }
7184                self.state.save()?;
7185            }
7186            Err(e) => {
7187                self.state.status = RunStatus::Blocked;
7188                self.state.event("land", format!("gave up: {e}"));
7189                self.state.save()?;
7190            }
7191        }
7192        Ok(())
7193    }
7194
7195    // -------------------------------------------------------------- helpers
7196
7197    /// Fetch or create a seat, keeping its conversation across nodes.
7198    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
7199        if let Some(existing) = self.state.seats.get(key)
7200            && existing.agent == agent
7201        {
7202            return existing.clone();
7203        }
7204        // A seat that changes agent mints its session id from the agent too,
7205        // like a handover: the old agent's uuid is already taken by the CLI.
7206        let fresh = if self.state.seats.contains_key(key) {
7207            handover_seat(key, agent, self.state.next_seat_seed())
7208        } else {
7209            SeatState::new(key, agent, self.state.seed)
7210        };
7211        self.state.seats.insert(key.to_owned(), fresh.clone());
7212        fresh
7213    }
7214
7215    /// The agent now holding seat `key`: `spec`, unless a handover moved the
7216    /// seat to another roster agent, in which case that agent. Nodes that
7217    /// continue a seat's conversation (deliberation, the votes, a reviewer's
7218    /// reconsideration) must keep talking to whoever answered it, not slip
7219    /// back to the agent that failed it.
7220    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
7221        match self.state.seats.get(key) {
7222            Some(s) if s.agent != spec.id => {
7223                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
7224            }
7225            _ => spec,
7226        }
7227    }
7228
7229    /// A candidate rendered for judging, with the leak policy applied.
7230    fn view(&self, c: &Candidate) -> CandidateView {
7231        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
7232            .unwrap_or_default();
7233        let (patch, _) = blind::sanitize_patch(
7234            &format!("candidate {} patch", c.label),
7235            &raw,
7236            &self.state.config.blind,
7237        );
7238        CandidateView {
7239            label: c.label,
7240            branch: c.branch.clone(),
7241            summary: c.summary.clone(),
7242            stat: c.stat.clone(),
7243            patch,
7244        }
7245    }
7246
7247    /// The full candidate set as prompt text, for seats with no live session.
7248    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
7249        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
7250        prompt::judge(
7251            "(see above)",
7252            &views,
7253            self.roles.judges.len(),
7254            base_short,
7255            "en",
7256        )
7257    }
7258
7259    /// The final-vote prompt with everything a seat that has no session of its
7260    /// own needs: the candidates, the seat's own ranking and reasons, and the
7261    /// anonymised deliberation it took part in (only when there was one, so a
7262    /// handed-over seat never sees more than the seat it replaces did). The
7263    /// `Final vote` heading stays first.
7264    fn vote_prompt_full(
7265        &self,
7266        j: usize,
7267        labels: &[char],
7268        language: &str,
7269        candidates: &[Candidate],
7270        base_short: &str,
7271    ) -> String {
7272        let mut text = format!(
7273            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
7274            prompt::final_vote(labels, language),
7275            self.state.instruction,
7276            self.candidate_block(candidates, base_short)
7277        );
7278        if let Some(own) = self
7279            .state
7280            .judgements
7281            .get(j)
7282            .filter(|r| !r.ranking.is_empty())
7283        {
7284            let reasons = own
7285                .reasons
7286                .iter()
7287                .map(|(k, v)| format!("- {k}: {v}"))
7288                .collect::<Vec<_>>()
7289                .join("\n");
7290            text.push_str(&format!(
7291                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
7292                own.ranking.iter().collect::<String>(),
7293                if reasons.is_empty() {
7294                    ""
7295                } else {
7296                    ", because:\n"
7297                }
7298            ));
7299        }
7300        if !self.state.deliberation.is_empty() {
7301            text.push_str("\n# What was argued before this vote\n");
7302            for t in self.transcript(&[], j) {
7303                text.push_str(&format!(
7304                    "\n## {}{}\n\n{}\n",
7305                    t.who,
7306                    if t.is_self { " (you)" } else { "" },
7307                    t.body.trim()
7308                ));
7309            }
7310        }
7311        text
7312    }
7313
7314    /// Anonymised transcript for judge `self_idx`.
7315    ///
7316    /// The initial rankings are always the opening statements. Seeding them
7317    /// only when no turn had been taken yet meant every judge after the first
7318    /// argued against a single voice instead of against the actual split — the
7319    /// disagreement is the information, so it is always on the table.
7320    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
7321        let mut turns = Vec::new();
7322        for j in &self.state.judgements {
7323            if j.ranking.is_empty() {
7324                continue;
7325            }
7326            let reasons = j
7327                .reasons
7328                .iter()
7329                .map(|(k, v)| format!("- {k}: {v}"))
7330                .collect::<Vec<_>>()
7331                .join("\n");
7332            turns.push(Turn {
7333                who: format!("Judge {} (opening ranking)", j.judge),
7334                is_self: j.judge == self_idx + 1,
7335                body: format!(
7336                    "Ranked {}{}{reasons}",
7337                    j.ranking.iter().collect::<String>(),
7338                    if reasons.is_empty() {
7339                        ""
7340                    } else {
7341                        ", because:\n"
7342                    }
7343                ),
7344            });
7345        }
7346        for t in self
7347            .state
7348            .deliberation
7349            .iter()
7350            .flat_map(|r| r.turns.iter())
7351            .chain(current)
7352        {
7353            turns.push(Turn {
7354                who: format!("Judge {}", t.judge),
7355                is_self: t.judge == self_idx + 1,
7356                body: t.body.clone(),
7357            });
7358        }
7359        turns
7360    }
7361}
7362
7363/// Does this seat still hold the context a follow-up prompt would rely on?
7364fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
7365    agent::has_session(spec.kind, seat, sessions)
7366}
7367
7368/// The next entry in `roster` after `start`, never wrapping back to the
7369/// front, whose id is not in `tried` yet.
7370///
7371/// Starts one past `start` rather than at the front of `roster`: `start` is
7372/// the seat's own original position, and a seat whose candidate slot already
7373/// sits on the roster's second entry must fall through to the third next, not
7374/// restart at the first — which is very likely a different candidate's own
7375/// agent already. Never wraps back past `start`, for the same reason: an
7376/// entry earlier in the roster than the seat's own position is almost
7377/// certainly some *other* candidate slot's own agent, and once the tail of
7378/// the roster is exhausted there are no more untried agents for *this* seat
7379/// to fall through to — the caller's fallback chain ends there, exactly as
7380/// "no further untried agents remain in the list for that seat" asks for.
7381///
7382/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
7383/// the same id twice (an operator's `roles.implementers` typo, or a
7384/// `[[agents]]` list reused across roles) must not let
7385/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
7386/// over `roster` either finds an untried id or runs out, so this always
7387/// terminates regardless of duplicates.
7388fn next_untried_in_roster<'a>(
7389    roster: &'a [AgentSpec],
7390    start: usize,
7391    tried: &BTreeSet<String>,
7392) -> Option<&'a AgentSpec> {
7393    roster
7394        .get(start + 1..)?
7395        .iter()
7396        .find(|s| !tried.contains(&s.id))
7397}
7398
7399/// The successor for a seat, shared by all four seat kinds (implement, judge,
7400/// review, advise). `others` holds the ids that *currently* occupy the other
7401/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
7402/// sees them), so roster entries beyond the seat count act as spares: a failed
7403/// seat goes to an agent no other seat holds whenever the roster permits.
7404///
7405/// `carried` is `Some` only for the review loop's carried failure history.
7406/// Order: (1) forward from `start`, never wrapping, untried, not a carried
7407/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
7408/// whole roster: untried and not another seat's occupant; (3) the plain walk
7409/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
7410/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
7411/// a better outcome than an empty seat, and it keeps the answer to "is there
7412/// a successor at all" exactly what it was before occupants were considered,
7413/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
7414/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
7415/// carried failure is a cheaper bet than doubling an agent on the panel, and
7416/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
7417/// the same round are handled one at a time, so a seat later in the batch
7418/// sees an earlier one's new occupant but not yet its own freed agent.
7419fn pick_successor<'a>(
7420    roster: &'a [AgentSpec],
7421    start: usize,
7422    tried: &BTreeSet<String>,
7423    carried: Option<&BTreeSet<String>>,
7424    others: &BTreeSet<String>,
7425) -> Option<&'a AgentSpec> {
7426    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
7427    roster
7428        .get(start + 1..)
7429        .and_then(|tail| {
7430            tail.iter()
7431                .filter(free)
7432                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
7433        })
7434        .or_else(|| {
7435            carried
7436                .is_some()
7437                .then(|| roster.iter().find(free))
7438                .flatten()
7439        })
7440        .or_else(|| match carried {
7441            Some(c) => next_for_seat(roster, start, tried, c),
7442            None => next_untried_in_roster(roster, start, tried),
7443        })
7444}
7445
7446/// The next agent for a seat that carries its failure history across rounds
7447/// (the review loop). `round_tried` is this round's own bound and starts
7448/// empty every round; `carried_failed` only decides priority.
7449///
7450/// First: an id walking forward from `start`, never wrapping, that is neither
7451/// tried this round nor failed in an earlier one. Only when that is exhausted
7452/// does it rescue: the first roster id (in roster order, so this one *does*
7453/// look before `start`) not yet tried this round, which is by then a carried
7454/// failure. Each id is rescued at most once per round, so it cannot loop.
7455fn next_for_seat<'a>(
7456    roster: &'a [AgentSpec],
7457    start: usize,
7458    round_tried: &BTreeSet<String>,
7459    carried_failed: &BTreeSet<String>,
7460) -> Option<&'a AgentSpec> {
7461    roster
7462        .get(start + 1..)?
7463        .iter()
7464        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
7465        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
7466}
7467
7468/// Where a reviewer seat starts a round: the agent that last answered it when
7469/// it is still on the roster and not marked failed, else the spec's own agent
7470/// unless it failed, else the next roster agent that has not failed, else the
7471/// spec's own agent again (the whole roster failed). Ids no longer on the
7472/// roster are ignored. An empty roster has no handover, so the spec stands.
7473fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
7474    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
7475        return spec;
7476    };
7477    let ok = |id: &str| !h.failed.contains(id);
7478    if let Some(last) = h.last_ok.as_deref()
7479        && ok(last)
7480        && let Some(s) = roster.iter().find(|s| s.id == last)
7481    {
7482        return s.clone();
7483    }
7484    if ok(&spec.id) {
7485        return spec;
7486    }
7487    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
7488    roster
7489        .get(from + 1..)
7490        .into_iter()
7491        .flatten()
7492        .chain(roster.iter())
7493        .find(|s| ok(&s.id))
7494        .cloned()
7495        .unwrap_or(spec)
7496}
7497
7498/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
7499/// seed so a CLI that mints its session id up front (`--session-id`) never
7500/// reuses the uuid the previous agent already opened under the same seat key.
7501pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7502    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7503}
7504
7505/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
7506/// so the classifier below and the code that builds the message cannot drift.
7507const TIMED_OUT: &str = "timed out";
7508
7509impl FailClass {
7510    /// `None` for an answer; otherwise how the turn failed.
7511    fn of(out: &AgentOutcome) -> Option<Self> {
7512        match out {
7513            AgentOutcome::Ok(_) => None,
7514            AgentOutcome::Quota(_) => Some(Self::Quota),
7515            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7516            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7517            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7518        }
7519    }
7520
7521    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
7522    fn stem_word(&self) -> &'static str {
7523        match self {
7524            Self::Quota => "quota",
7525            _ => "handover",
7526        }
7527    }
7528}
7529
7530/// The message's first line with its variable parts removed — digit runs and
7531/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
7532/// as one kind of failure.
7533fn failure_signature(msg: &str) -> String {
7534    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7535    let mut out = Vec::new();
7536    for word in line.split_whitespace() {
7537        if word.contains('/') || word.contains('\\') {
7538            out.push("<path>".to_owned());
7539            continue;
7540        }
7541        let mut w = String::new();
7542        let mut in_digits = false;
7543        for c in word.chars() {
7544            if c.is_ascii_digit() {
7545                if !in_digits {
7546                    w.push('#');
7547                }
7548                in_digits = true;
7549            } else {
7550                in_digits = false;
7551                w.push(c);
7552            }
7553        }
7554        out.push(w);
7555    }
7556    out.join(" ").chars().take(120).collect()
7557}
7558
7559/// Whether a seat that just failed with `cur` may go to the next roster agent.
7560/// A quota or a timeout always may. Any other failure may not when the agent
7561/// before it failed the same way: an error the prompt causes would otherwise
7562/// walk the whole roster. `prev` is the class of the immediately preceding
7563/// agent's failure, so a quota or timeout in between breaks the run of
7564/// identical failures by itself.
7565fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7566    match cur {
7567        FailClass::Quota | FailClass::Timeout => true,
7568        FailClass::Other(_) => prev != Some(cur),
7569    }
7570}
7571
7572/// A short human reason for a failed outcome, for the handover record.
7573fn fail_reason(out: &AgentOutcome) -> String {
7574    match out {
7575        AgentOutcome::Ok(_) => String::new(),
7576        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7577        AgentOutcome::Dropped(o) => format!(
7578            "the CLI dropped the stream ({})",
7579            o.dropped
7580                .as_ref()
7581                .map(|d| d.why.as_str())
7582                .unwrap_or("it ended without delivering its answer")
7583        ),
7584        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7585    }
7586}
7587
7588/// Note one handover in the run: the structured record and, in the timeline,
7589/// the sentence a person reads. A quota keeps the wording it always had.
7590pub(crate) fn record_handover(
7591    state: &mut RunState,
7592    node: &str,
7593    seat: &str,
7594    from: &str,
7595    to: &str,
7596    class: &FailClass,
7597    reason: &str,
7598) {
7599    let message = if *class == FailClass::Quota {
7600        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7601    } else {
7602        format!("{seat}: handed over {from} -> {to} ({reason})")
7603    };
7604    state.event(node, message);
7605    state.handovers.push(Handover {
7606        at: Timestamp::now(),
7607        node: node.to_owned(),
7608        seat: seat.to_owned(),
7609        from: from.to_owned(),
7610        to: to.to_owned(),
7611        reason: reason.to_owned(),
7612    });
7613}
7614
7615/// Did this reply report running a command whose own CLI never confirmed an
7616/// exit status?
7617///
7618/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
7619/// command *finished* (see that type's own doc), so this can only be `true`
7620/// for a command whose completion event carried no readable exit code — not
7621/// for one that simply is not mentioned at all. That is the one signal this
7622/// crate can read, from the same record `state.jobs` renders, about a reply
7623/// standing next to work its own CLI cannot vouch for finishing; it is
7624/// deliberately not a check on the exit code's *value* (a fixer legitimately
7625/// runs a command that fails mid-iteration before it succeeds) and not a
7626/// guess at a command still running in the background (which emits no event
7627/// at all, and so leaves no evidence here to find).
7628fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7629    commands.iter().any(|c| c.exit_code.is_none())
7630}
7631
7632/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
7633/// trusted as a verified no-op — the adoption guard's own text-level half.
7634///
7635/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
7636/// timed out): a marker only earns the benefit of the doubt from a turn the
7637/// CLI itself vouches for finishing properly, the same house style
7638/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
7639/// *fix* report to for `commands`. A candidate that timed out, exited
7640/// non-zero, or left a command unconfirmed is read as the ordinary loss it
7641/// is, whatever prose it wrote — this returns `None` before it ever looks at
7642/// `text`. The remaining guards (the tree really is empty, the evidence is
7643/// non-empty) are the caller's: this only reads what the reply *claimed*.
7644fn verified_noop_claim(
7645    usable: bool,
7646    commands: &[agent::CommandEvidence],
7647    text: &str,
7648) -> Option<String> {
7649    (usable && !has_unconfirmed_command(commands))
7650        .then(|| verdict::verified_noop(text))
7651        .flatten()
7652}
7653
7654fn short(commit: &str) -> String {
7655    commit.chars().take(7).collect()
7656}
7657
7658fn make_executable(path: &Path) -> Result<()> {
7659    #[cfg(unix)]
7660    {
7661        use std::os::unix::fs::PermissionsExt as _;
7662        let mut perms = std::fs::metadata(path)?.permissions();
7663        perms.set_mode(0o755);
7664        std::fs::set_permissions(path, perms)?;
7665    }
7666    #[cfg(not(unix))]
7667    {
7668        let _ = path;
7669    }
7670    Ok(())
7671}
7672
7673/// What every seat in one batch shares: where the answers are attributed, the
7674/// prompt overlay they inherit, and the build cache they are told to use.
7675///
7676/// A struct rather than four more parameters: `wave` also needs the run's
7677/// state (to record who is answering right now) and the attempt number, and
7678/// eight positional arguments is both unreadable and a clippy error.
7679struct WaveCtx<'a> {
7680    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
7681    /// paid for it.
7682    run: &'a str,
7683    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
7684    node: &'a str,
7685    prompts: &'a Prompts,
7686    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
7687    cache: Option<&'a Path>,
7688    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
7689    /// `JobRecord::round`. `None` for every other node.
7690    round: Option<usize>,
7691    /// Carry each seat's failed-agent history across waves (the review loop
7692    /// only): start-of-round priority and handover choice read
7693    /// [`RunState::seat_history`], and every answer or failure writes it.
7694    carry_seats: bool,
7695}
7696
7697/// Run one job, honouring the parallelism budget.
7698async fn run_one(
7699    job: SeatJob,
7700    sem: Arc<Semaphore>,
7701    ctx: &WaveCtx<'_>,
7702    state: &mut RunState,
7703    attempt: usize,
7704) -> (SeatState, AgentOutcome) {
7705    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7706        .await
7707        .pop()
7708        .expect("one job in, one result out");
7709    (seat, out)
7710}
7711
7712/// Run every job concurrently, capped by the semaphore, preserving order.
7713///
7714/// Every seat in the batch is recorded into [`RunState::active`] before the
7715/// wave starts and cleared as each answer lands, so the run's own record says
7716/// who is still being waited on rather than only who finished.
7717async fn wave(
7718    jobs: Vec<SeatJob>,
7719    sem: Arc<Semaphore>,
7720    ctx: &WaveCtx<'_>,
7721    state: &mut RunState,
7722    attempt: usize,
7723) -> Vec<(usize, SeatState, AgentOutcome)> {
7724    let WaveCtx {
7725        run,
7726        node,
7727        prompts,
7728        cache,
7729        round,
7730        carry_seats: _,
7731    } = *ctx;
7732    for job in &jobs {
7733        state.seat_started(node, &job.seat.key, job.timeout, attempt);
7734    }
7735    if let Err(e) = state.save() {
7736        // A failed persist of "who is answering right now" must not abort the
7737        // wave: the seats are already being asked, and the alternative is
7738        // losing the answers to save a status line nobody may even be
7739        // watching.
7740        tracing::warn!("could not persist in-progress seats: {e:#}");
7741    }
7742    // Hold the shared build cache's lease for the whole batch, not per job:
7743    // several candidates (an implement wave) or a fixer legitimately share
7744    // one cache concurrently within this run, and that stays untouched — a
7745    // single lease taken once for the whole wave and released once it is
7746    // done is what stops a *different* borrower (another run's own wave, its
7747    // e2e/gate, a human's `magi review`) from interleaving a build into the
7748    // same directory while this one is in flight. Best-effort, not
7749    // all-or-nothing: a wave that cannot get the lease within its own
7750    // longest job's budget still runs — an hour of paid implementer calls is
7751    // not thrown away over cache contention — but every write-allowed seat
7752    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
7753    // below), the same fallback a read-only seat always gets, rather than
7754    // building into a directory this run was never granted. The identity
7755    // record is still invalidated below either way, so the next tracked
7756    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
7757    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7758    let wait_started = Instant::now();
7759    let cache_guard = if let Some(cache_dir) = cache {
7760        if jobs_had_a_writer {
7761            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7762            let budget = jobs
7763                .iter()
7764                .map(|j| j.timeout)
7765                .max()
7766                .unwrap_or(Duration::from_secs(60));
7767            acquire_cache_lease(state, cache_dir, &owner, budget, node)
7768                .await
7769                .ok()
7770        } else {
7771            None
7772        }
7773    } else {
7774        None
7775    };
7776    // Carved out of each job's own budget, not added on top of it: a seat
7777    // that waited behind the lease must not also get its full timeout
7778    // afterward, or a run contended on the cache could double the time it
7779    // spends per wave. `saturating_sub` floors at zero rather than
7780    // wrapping - a job whose whole budget was spent waiting starts with
7781    // none left, which is the honest number, not a free minimum.
7782    let waited_for_lease = wait_started.elapsed();
7783    let mut set = tokio::task::JoinSet::new();
7784    let overlay = prompts.overlay(node);
7785    for (i, mut job) in jobs.into_iter().enumerate() {
7786        job.timeout = job.timeout.saturating_sub(waited_for_lease);
7787        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7788        if cache.is_some() {
7789            job.prompt.push('\n');
7790            job.prompt
7791                .push_str(&prompt::build_cache_note(node, job.allow_write));
7792        }
7793        let sem = Arc::clone(&sem);
7794        let run = run.to_owned();
7795        let node = node.to_owned();
7796        // Only implementers were told about the task's attachments, so only
7797        // their seats get the directory widened for reading.
7798        let attachments = if node == "implement" {
7799            state.attachments.clone()
7800        } else {
7801            Vec::new()
7802        };
7803        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
7804        // `prompt::build_cache_note`'s doc for why setting it anyway is
7805        // exactly how a sandboxed reviewer's write refusal got reported as a
7806        // defect in the patch, not a property of its own seat. And a
7807        // write-allowed one is handed it only when the lease above was
7808        // actually acquired: a wave that could not get it (`cache_guard` is
7809        // `None`, see its own comment) must not send seats to build into a
7810        // directory this run does not hold - that is the exact concurrent,
7811        // unmanaged-write race this module exists to prevent, not something
7812        // "proceeding anyway" is allowed to reintroduce.
7813        let cache = cache
7814            .filter(|_| job.allow_write && cache_guard.is_some())
7815            .map(Path::to_path_buf);
7816        set.spawn(async move {
7817            let _permit = sem.acquire().await;
7818            let mut seat = job.seat;
7819            let out = agent::invoke(
7820                &job.spec,
7821                &mut seat,
7822                &Invocation {
7823                    cwd: &job.cwd,
7824                    prompt: &job.prompt,
7825                    timeout: job.timeout,
7826                    allow_write: job.allow_write,
7827                    unsandboxed: false,
7828                    sessions: job.sessions,
7829                    artifacts: &job.artifacts,
7830                    stem: &job.stem,
7831                    run: &run,
7832                    node: &node,
7833                    cache_dir: cache.as_deref(),
7834                    attachments: &attachments,
7835                    writable: &[],
7836                },
7837            )
7838            .await;
7839            let out = match out {
7840                Ok(o) if o.usable() => AgentOutcome::Ok(o),
7841                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7842                // Billed work the CLI failed to hand over is not an ordinary
7843                // failure, but its text is the CLI's raw error JSON, not an
7844                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
7845                // read it as one by forgetting to check. `usable()` is always
7846                // false here (dropped implies an empty response), so this has
7847                // to be checked before the catch-all `Failed` below or the
7848                // one shape this exists for is lost with the rest.
7849                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7850                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7851                Ok(o) => AgentOutcome::Failed(format!(
7852                    "exited with {:?} and no usable output",
7853                    o.exit_code
7854                )),
7855                Err(e) => AgentOutcome::Failed(e.to_string()),
7856            };
7857            (i, seat, out)
7858        });
7859    }
7860    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7861    while let Some(joined) = set.join_next().await {
7862        let (i, seat, out) = match joined {
7863            Ok(v) => v,
7864            // No seat to clear: a panicked task never reported which one it
7865            // was. The defensive sweep below this loop is what stops that
7866            // seat's `active` entry from surviving forever.
7867            Err(e) => {
7868                tracing::error!("agent task panicked: {e}");
7869                continue;
7870            }
7871        };
7872        state.seat_finished(&seat.key);
7873        record_jobs(state, node, round, &seat.key, &out);
7874        if let Err(e) = state.save() {
7875            tracing::warn!("could not persist a seat's completion: {e:#}");
7876        }
7877        if collected.len() <= i {
7878            collected.resize_with(i + 1, || None);
7879        }
7880        collected[i] = Some((i, seat, out));
7881    }
7882    // Belt-and-braces for the panic branch above: every seat this exact batch
7883    // started shares this `(node, attempt)` pair, and every seat that finished
7884    // normally already cleared itself, so anything left tagged with it here
7885    // can only be a panicked task's leftover. Cleared unconditionally rather
7886    // than left to read as still answering forever.
7887    if state
7888        .active
7889        .values()
7890        .any(|a| a.node == node && a.attempt == attempt)
7891    {
7892        state
7893            .active
7894            .retain(|_, a| !(a.node == node && a.attempt == attempt));
7895        if let Err(e) = state.save() {
7896            tracing::warn!("could not persist the end of a wave: {e:#}");
7897        }
7898    }
7899    // Whether or not the lease above was actually held, several worktrees
7900    // may just have built into the cache with nothing here able to name one
7901    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
7902    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
7903    // clean it might not have strictly needed; trusting a stale match would
7904    // cost it a wrong answer.
7905    if let Some(cache_dir) = cache
7906        && jobs_had_a_writer
7907    {
7908        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7909    }
7910    if let Some(guard) = cache_guard {
7911        guard.release();
7912    }
7913    collected.into_iter().flatten().collect()
7914}
7915
7916/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
7917/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
7918/// this is data collection, not the fix-specific completion contract in
7919/// [`Runner::continue_fix_report`], and applies regardless of which node
7920/// asked.
7921///
7922/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
7923/// evidence from; `Failed` does not, and correctly contributes nothing — a
7924/// timeout or crash is not itself evidence about a command the seat may have
7925/// started.
7926fn record_jobs(
7927    state: &mut RunState,
7928    node: &str,
7929    round: Option<usize>,
7930    seat: &str,
7931    out: &AgentOutcome,
7932) {
7933    let commands: &[agent::CommandEvidence] = match out {
7934        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7935        AgentOutcome::Failed(_) => &[],
7936    };
7937    let checked_at = Timestamp::now();
7938    for c in commands {
7939        state.jobs.push(JobRecord {
7940            node: node.to_owned(),
7941            round,
7942            seat: seat.to_owned(),
7943            id: c.id.clone(),
7944            description: c.description.clone(),
7945            checked_at,
7946            status: match c.exit_code {
7947                Some(0) => JobStatus::Completed,
7948                Some(_) => JobStatus::Failed,
7949                None => JobStatus::Unknown,
7950            },
7951            exit_code: c.exit_code,
7952            result_summary: c.result_summary.clone(),
7953            source: c.source.clone(),
7954        });
7955    }
7956}
7957
7958/// Is a review round clean, given how many reviewer seats answered against
7959/// how many the round expected?
7960///
7961/// A seat that never answered (timeout, crash, unparsable output) is not a
7962/// seat that read the patch and found nothing — treating it as such is
7963/// exactly the bug this function exists to close. Under the default `block`
7964/// policy a missing seat can never be clean; `warn` still requires the seats
7965/// that *did* answer to have found nothing blocking and verification to be
7966/// green.
7967///
7968/// `quota_missing` narrows that `block` default for exactly one cause of
7969/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
7970/// a session limit lifts by the very next round buys nothing — the seat is
7971/// asked again with the same quota — so once every missing seat is accounted
7972/// for by a quota loss (and at least one seat *did* answer, so a decision has
7973/// something to rest on) the round is decided on the panel that could answer,
7974/// same as `warn` would. A panel that lost every seat to quota is not
7975/// decided here: `answered == 0` falls through to the existing `block`
7976/// fallback so a fully collapsed panel still waits rather than landing on no
7977/// review at all.
7978fn round_is_clean(
7979    blocking: usize,
7980    e2e_ok: bool,
7981    answered: usize,
7982    expected: usize,
7983    quota_missing: usize,
7984    policy: IncompleteReviewPolicy,
7985) -> bool {
7986    if blocking != 0 || !e2e_ok {
7987        return false;
7988    }
7989    if answered == expected || policy == IncompleteReviewPolicy::Warn {
7990        return true;
7991    }
7992    answered > 0 && expected - answered <= quota_missing
7993}
7994
7995/// The review loop's own conclusion, derived entirely from its persisted
7996/// round records and the round budget that produced them — never from
7997/// `status`, so a reentry (or `gate`/`merge` reading it independently)
7998/// recomputes the identical answer regardless of what an earlier node in the
7999/// same walk, or a previous walk, did to `status`.
8000///
8001/// `None` while more rounds remain to try, including when review never ran
8002/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
8003/// gone clean, or the budget is spent, or the tree has stopped moving (see
8004/// [`STAGNANT_LIMIT`]), the answer is one of two things:
8005///
8006/// - An incomplete panel that raised nothing is missing input, not a
8007///   verified tree — never a hand-off candidate, whatever verification said
8008///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
8009/// - Otherwise, green e2e on the last round hands off (see
8010///   [`Runner::stop_reviewing`]); red e2e blocks.
8011///
8012/// A last round whose own verification is still `ResourceBlocked` — magi
8013/// itself never got a command to run, not evidence the patch is broken —
8014/// is neither: this returns `None` for it too, the same as "more rounds
8015/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
8016/// handling of that shape) instead of this cheap recomputation guessing a
8017/// verdict a real attempt never produced.
8018fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
8019    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
8020        return Some(RunStatus::Gating);
8021    }
8022    let last = reviews.last()?;
8023    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
8024    if reviews.len() < max_rounds && !stagnant {
8025        return None;
8026    }
8027    if last.incomplete() && last.blocking == 0 {
8028        return Some(RunStatus::Blocked);
8029    }
8030    if last.e2e_status() == E2eStatus::ResourceBlocked {
8031        return None;
8032    }
8033    Some(if last.e2e.iter().all(CommandOutcome::ok) {
8034        RunStatus::Gating
8035    } else {
8036        RunStatus::Blocked
8037    })
8038}
8039
8040/// How long a re-ask may take, given the budget the first attempt had.
8041///
8042/// A `nudged` retry is a request to restate an answer the seat has already
8043/// worked out: it carries no new work, so it does not deserve the original
8044/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
8045/// 133 seconds while a third sat for over ten minutes on a resumed session
8046/// holding 410 KB of prior output - and because the retry had inherited the
8047/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
8048/// judging round whose other seats were long finished.
8049///
8050/// A quarter of the budget, with a floor so that a deliberately short timeout
8051/// does not collapse to nothing. A retry that re-sends the whole prompt
8052/// (because the seat kept no context) is the original job again, and keeps the
8053/// original budget.
8054pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
8055    if nudged {
8056        (full / 4).max(Duration::from_secs(120)).min(full)
8057    } else {
8058        full
8059    }
8060}
8061
8062/// Run a wave and parse each reply, re-asking the seats whose reply was
8063/// unusable.
8064///
8065/// The re-ask is a nudge rather than the whole prompt again when the seat still
8066/// holds its conversation, which is the difference between a cheap retry and
8067/// paying for the entire candidate set twice.
8068///
8069/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
8070/// successor in `roster` is handed to it instead of being re-asked: the
8071/// handover is the retry. A seat with no successor left (a single-agent
8072/// roster, the roster's tail) is nudged as before, up to `retries` times. So
8073/// the asks to one seat in one node number at most
8074/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
8075/// is asked once (a dropped stream is nudged first), and only the last agent
8076/// of the chain gets the `retries` same-agent nudges. Each roster agent is
8077/// tried at most once per seat, walking forward from the seat's own position and never wrapping
8078/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
8079/// other failure stops the chain when the previous agent failed the same way
8080/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
8081/// [`has_context`] is false and the job's own full prompt and full budget are
8082/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
8083/// intermediate ones are not losses) and is returned as a failure like any
8084/// other absent seat — the caller decides whether the panel still has a
8085/// quorum. An empty `roster` disables handover: failures are nudged as they
8086/// always were, and a quota is simply lost. A reply that fails to parse or
8087/// validate is the prompt's doing and is only ever nudged, never handed over.
8088///
8089/// The returned [`SeatState`] names the agent that answered (or tried last).
8090#[allow(clippy::too_many_arguments)]
8091async fn ask_json_wave<T>(
8092    jobs: Vec<SeatJob>,
8093    sem: Arc<Semaphore>,
8094    retries: usize,
8095    roster: &[AgentSpec],
8096    ctx: &WaveCtx<'_>,
8097    losses: &mut Vec<QuotaLoss>,
8098    state: &mut RunState,
8099    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
8100) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8101where
8102    T: serde::de::DeserializeOwned + Send + 'static,
8103{
8104    ask_wave_with(
8105        jobs,
8106        sem,
8107        retries,
8108        roster,
8109        ctx,
8110        losses,
8111        state,
8112        &|text: &str| {
8113            let v = verdict::extract_json::<T>(text)?;
8114            validate(&v)?;
8115            Ok(v)
8116        },
8117    )
8118    .await
8119}
8120
8121/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
8122/// a node whose answer is prose (deliberation) shares the same handover,
8123/// failure classification, quota bookkeeping and bounds instead of a copy.
8124///
8125/// A seat handed to another roster agent is sent the job's `handover` prompt
8126/// (when it has one) rather than `prompt`: the new agent has no session, so a
8127/// resume-style prompt would be incomplete. That holds for the handover ask
8128/// and for every nudge to that agent whose `has_context` is false.
8129#[allow(clippy::too_many_arguments)]
8130async fn ask_wave_with<T>(
8131    jobs: Vec<SeatJob>,
8132    sem: Arc<Semaphore>,
8133    retries: usize,
8134    roster: &[AgentSpec],
8135    ctx: &WaveCtx<'_>,
8136    losses: &mut Vec<QuotaLoss>,
8137    state: &mut RunState,
8138    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
8139) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
8140where
8141    T: Send + 'static,
8142{
8143    let n = jobs.len();
8144    let originals: Vec<SeatJob> = jobs;
8145    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
8146    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
8147    // Nudges each seat's *current* agent has taken — 0 for a first-ask
8148    // answer, N once it has gone through N nudges. Read back once this
8149    // returns, so a caller building a history record (`ReviewRecord`) can
8150    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
8151    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
8152    // field's own doc.
8153    let mut nudges: Vec<usize> = vec![0; n];
8154    // The agent now occupying each seat, the ids it has already been through,
8155    // where in the roster the walk began, the class of the last failure, and
8156    // the stem word of a handover not yet asked (full prompt, full budget).
8157    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
8158    let mut tried: Vec<BTreeSet<String>> = specs
8159        .iter()
8160        .map(|s| BTreeSet::from([s.id.clone()]))
8161        .collect();
8162    let starts: Vec<usize> = specs
8163        .iter()
8164        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
8165        .collect();
8166    let carry = ctx.carry_seats && !roster.is_empty();
8167    // Carried across rounds: ids that failed the seat earlier, and how the
8168    // last failure went (so a repeat of it is not handed over again).
8169    let carried: Vec<BTreeSet<String>> = originals
8170        .iter()
8171        .map(|j| {
8172            state
8173                .seat_history
8174                .get(&j.seat.key)
8175                .filter(|_| carry)
8176                .map(|h| h.failed.clone())
8177                .unwrap_or_default()
8178        })
8179        .collect();
8180    let mut prev: Vec<Option<FailClass>> = originals
8181        .iter()
8182        .map(|j| {
8183            state
8184                .seat_history
8185                .get(&j.seat.key)
8186                .filter(|_| carry)
8187                .and_then(|h| h.last_fail.clone())
8188        })
8189        .collect();
8190    let next_agent =
8191        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
8192            let others: BTreeSet<String> = specs
8193                .iter()
8194                .enumerate()
8195                .filter(|(j, _)| *j != i)
8196                .map(|(_, s)| s.id.clone())
8197                .collect();
8198            pick_successor(
8199                roster,
8200                starts[i],
8201                tried,
8202                carry.then(|| &carried[i]),
8203                &others,
8204            )
8205            .cloned()
8206        };
8207    let mut fresh: Vec<Option<String>> = vec![None; n];
8208    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
8209    let mut pending: Vec<usize> = (0..n).collect();
8210
8211    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
8212    // asks: an agent with a successor is asked once and handed over, and only
8213    // a seat with no successor spends `retries` nudges on the same agent. This
8214    // only guarantees the loop's own termination whatever those say.
8215    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
8216    for round in 0..max_rounds {
8217        if pending.is_empty() {
8218            break;
8219        }
8220        let mut batch = Vec::with_capacity(pending.len());
8221        let mut renudged: Vec<&str> = Vec::new();
8222        for &i in &pending {
8223            let src = &originals[i];
8224            // A seat now held by another agent than the job named has no
8225            // session of its own: it gets the full-context prompt whenever
8226            // it is asked in full (the handover ask, a nudge it cannot
8227            // resume).
8228            let full: &str = match &src.handover {
8229                Some(h) if specs[i].id != src.spec.id => h,
8230                _ => &src.prompt,
8231            };
8232            // The prompt and the budget are one decision: a nudge restates
8233            // finished work, a re-sent prompt redoes it.
8234            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
8235                (
8236                    full.to_owned(),
8237                    src.timeout,
8238                    format!("{}-{word}-{}", src.stem, specs[i].id),
8239                )
8240            } else if nudges[i] == 0 {
8241                (full.to_owned(), src.timeout, src.stem.clone())
8242            } else {
8243                renudged.push(src.seat.key.as_str());
8244                let why = done[i]
8245                    .as_ref()
8246                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
8247                    .unwrap_or_else(|| "no parsable answer".to_owned());
8248                let nudge = prompt::nudge(&why);
8249                let nudged = has_context(&specs[i], &seats[i], src.sessions);
8250                let prompt = if nudged {
8251                    nudge
8252                } else {
8253                    format!("{full}\n\n---\n\n{nudge}")
8254                };
8255                (
8256                    prompt,
8257                    retry_budget(src.timeout, nudged),
8258                    format!("{}-retry{}", src.stem, nudges[i]),
8259                )
8260            };
8261            batch.push(SeatJob {
8262                spec: specs[i].clone(),
8263                seat: seats[i].clone(),
8264                cwd: src.cwd.clone(),
8265                prompt,
8266                timeout,
8267                allow_write: src.allow_write,
8268                sessions: src.sessions,
8269                artifacts: src.artifacts.clone(),
8270                stem,
8271                handover: None,
8272            });
8273        }
8274
8275        if !renudged.is_empty() {
8276            state.event(
8277                ctx.node,
8278                format!("retry {round}: re-asking {}", renudged.join(", ")),
8279            );
8280        }
8281        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
8282        let mut still = Vec::new();
8283        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
8284            seats[i] = seat;
8285            let class = FailClass::of(&out);
8286            // A dropped stream is nudged first (the conversation is still
8287            // there to pick up); only a seat whose nudges are spent hands over.
8288            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
8289                && nudges[i] < retries
8290                && !roster.is_empty();
8291            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
8292                let next = should_hand_over(prev[i].as_ref(), &cur)
8293                    .then(|| next_agent(i, &tried[i], &specs))
8294                    .flatten();
8295                if carry {
8296                    let h = state
8297                        .seat_history
8298                        .entry(originals[i].seat.key.clone())
8299                        .or_default();
8300                    h.failed.insert(specs[i].id.clone());
8301                    h.last_fail = Some(cur.clone());
8302                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
8303                        h.last_ok = None;
8304                    }
8305                    // Saved before the next agent is asked, so a restart in
8306                    // between does not forget who failed.
8307                    if let Err(e) = state.save() {
8308                        tracing::warn!("could not persist a seat's failure history: {e:#}");
8309                    }
8310                }
8311                if let Some(next) = next {
8312                    record_handover(
8313                        state,
8314                        ctx.node,
8315                        &originals[i].seat.key,
8316                        &specs[i].id,
8317                        &next.id,
8318                        &cur,
8319                        &fail_reason(&out),
8320                    );
8321                    tried[i].insert(next.id.clone());
8322                    prev[i] = Some(cur.clone());
8323                    seats[i] =
8324                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
8325                    specs[i] = next;
8326                    fresh[i] = Some(cur.stem_word().to_owned());
8327                    nudges[i] = 0;
8328                    done[i] = Some(Err(anyhow::anyhow!(
8329                        "handed over after: {}",
8330                        fail_reason(&out)
8331                    )));
8332                    still.push(i);
8333                    continue;
8334                }
8335            }
8336            if carry
8337                && !nudge_first
8338                && let Some(cur) = class.clone()
8339            {
8340                // The chain ended here (no successor, or a repeated failure).
8341                let h = state
8342                    .seat_history
8343                    .entry(originals[i].seat.key.clone())
8344                    .or_default();
8345                h.failed.insert(specs[i].id.clone());
8346                h.last_fail = Some(cur);
8347            }
8348            let parsed = match out {
8349                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
8350                AgentOutcome::Quota(o) => {
8351                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
8352                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
8353                }
8354                // Not a parseable answer: the nudge loop re-asks it, which is
8355                // exactly what a dropped stream needs. Just don't hand its raw
8356                // error JSON to `extract_json`.
8357                AgentOutcome::Dropped(o) => {
8358                    let why = o
8359                        .dropped
8360                        .as_ref()
8361                        .map(|d| d.why.as_str())
8362                        .unwrap_or("the CLI ended the stream without delivering its answer");
8363                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
8364                }
8365                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
8366            };
8367            let quota = class == Some(FailClass::Quota);
8368            let failed = parsed.is_err();
8369            done[i] = Some(parsed);
8370            if carry && !failed {
8371                let h = state
8372                    .seat_history
8373                    .entry(originals[i].seat.key.clone())
8374                    .or_default();
8375                h.failed.remove(&specs[i].id);
8376                h.last_ok = Some(specs[i].id.clone());
8377                h.last_fail = None;
8378            }
8379            // Do not re-ask a rate-limited seat (quota) — a retry is known to
8380            // fail the same way; and never re-ask a seat that already parsed.
8381            // A failed agent that still has a successor is not re-asked
8382            // either: the handover was its remedy and has just been refused
8383            // (the chain stops on a repeated failure class). A seat with no
8384            // successor left (a single-agent roster, the roster's tail, or an
8385            // empty roster) keeps the same-agent nudge, bounded by `retries`.
8386            let agent_failure = class.is_some()
8387                && !nudge_first
8388                && !roster.is_empty()
8389                && next_agent(i, &tried[i], &specs).is_some();
8390            if failed && !quota && !agent_failure && nudges[i] < retries {
8391                nudges[i] += 1;
8392                still.push(i);
8393            }
8394        }
8395        pending = still;
8396    }
8397
8398    // One loss per seat whose chain ended on a quota: the intermediate ones
8399    // were absorbed by a handover and are not losses.
8400    for (i, q) in last_quota.into_iter().enumerate() {
8401        if let Some(reset) = q {
8402            losses.push(QuotaLoss {
8403                seat: originals[i].seat.key.clone(),
8404                node: ctx.node.to_owned(),
8405                at: Timestamp::now(),
8406                reset,
8407            });
8408        }
8409    }
8410
8411    seats
8412        .into_iter()
8413        .zip(done)
8414        .zip(nudges)
8415        .map(|((seat, res), attempts)| {
8416            (
8417                seat,
8418                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
8419                attempts,
8420            )
8421        })
8422        .collect()
8423}
8424
8425/// Acquire the shared build cache's lease, waiting out contention within
8426/// `budget` (never past it — see AGENTS.md's build-cache section on why an
8427/// unbounded wait is never acceptable).
8428///
8429/// A first, non-blocking check happens before ever waiting; if it finds the
8430/// lease busy, that fact is logged as a `verify` event *and* flushed with
8431/// [`RunState::save`] immediately — not only once the wait finally succeeds
8432/// or gives up — so a `magi show` run by a different process while this one
8433/// is still waiting reads a `run.json` that says so, rather than whatever it
8434/// looked like before the wait started. The same applies to the terminal
8435/// failure: logged and saved before this returns `Err`, so a caller that
8436/// could not get the lease at all still leaves a legible record of why.
8437async fn acquire_cache_lease(
8438    state: &mut RunState,
8439    cache_dir: &Path,
8440    owner: &crate::cache::Owner,
8441    budget: Duration,
8442    context: &str,
8443) -> Result<crate::cache::Guard> {
8444    let home = crate::run::home();
8445    let started = Instant::now();
8446    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
8447        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
8448        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
8449        Err(e) => {
8450            state.event(
8451                "verify",
8452                format!("{context}: could not check the shared build cache: {e:#}"),
8453            );
8454            if let Err(e2) = state.save() {
8455                tracing::warn!("could not persist a cache-check failure: {e2:#}");
8456            }
8457            return Err(e);
8458        }
8459    };
8460    state.event(
8461        "verify",
8462        format!(
8463            "{context}: waiting for the shared build cache at {} ({})",
8464            cache_dir.display(),
8465            busy.describe()
8466        ),
8467    );
8468    if let Err(e) = state.save() {
8469        tracing::warn!("could not persist a cache wait: {e:#}");
8470    }
8471    let remaining = budget.saturating_sub(started.elapsed());
8472    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
8473        Ok(g) => Ok(g),
8474        Err(e) => {
8475            state.event("verify", format!("{context}: {e:#}"));
8476            if let Err(e2) = state.save() {
8477                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
8478            }
8479            Err(e)
8480        }
8481    }
8482}
8483
8484/// Run `body` — a verify command batch — while holding the shared build
8485/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
8486/// never interleave with another borrower's build against the same
8487/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
8488/// timeout, or a human's own `magi review`. See the `cache` module doc for
8489/// why this matters more than Cargo's own per-target locking covers — two
8490/// *different* worktrees building the same package name/version into one
8491/// cache directory is a staleness bug, not a lock contention one.
8492///
8493/// The wait for the lease is carved out of `budget`, never on top of it —
8494/// `body` is handed whatever is left, so a caller's own node timeout is the
8495/// only clock involved, exactly what AGENTS.md's build-cache section asks
8496/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
8497/// cache configured at all — this is a pass-through: `body` runs with the
8498/// full budget and nothing is leased.
8499///
8500/// A lease that cannot be acquired within `budget` is reported as a single
8501/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
8502/// verification — the same shape a spawn failure already takes in
8503/// [`run_commands`], so a caller need not special-case it.
8504#[allow(clippy::too_many_arguments)]
8505async fn with_cache_lease<'s, F, Fut>(
8506    state: &'s mut RunState,
8507    cache_dir: Option<&Path>,
8508    node: &str,
8509    seat: &str,
8510    worktree: &Path,
8511    head: &str,
8512    budget: Duration,
8513    context: &str,
8514    body: F,
8515) -> (Vec<CommandOutcome>, bool)
8516where
8517    F: FnOnce(&'s mut RunState, Duration) -> Fut,
8518    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8519{
8520    let Some(cache_dir) = cache_dir else {
8521        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8522        return (outcomes, retried);
8523    };
8524    let home = crate::run::home();
8525    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8526    let started = Instant::now();
8527    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8528        Ok(g) => g,
8529        Err(e) => {
8530            return (
8531                vec![CommandOutcome {
8532                    command: "(waiting for the shared build cache)".to_owned(),
8533                    code: None,
8534                    output_tail: e.to_string(),
8535                    duration_ms: started.elapsed().as_millis() as u64,
8536                    resource_blocked: true,
8537                }],
8538                false,
8539            );
8540        }
8541    };
8542    let identity = crate::cache::Identity::new(worktree, head);
8543    if let Err(e) = crate::cache::ensure_fresh(
8544        &home,
8545        cache_dir,
8546        &identity,
8547        &state.config.verify.cargo_manifests(),
8548    ) {
8549        // A failed freshness check means this process cannot vouch for what
8550        // is sitting in the cache right now - on Windows this is exactly the
8551        // "a stale test executable is still locked, `cargo clean -p` cannot
8552        // remove it" case the evidence log records. Running verify anyway
8553        // and reporting whatever it says would let a result nobody can trust
8554        // stand for the tree it claims to have checked; fail the step
8555        // instead of the patch.
8556        state.event(
8557            "verify",
8558            format!(
8559                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8560                worktree.display(),
8561                short(head)
8562            ),
8563        );
8564        guard.release();
8565        return (
8566            vec![CommandOutcome {
8567                command: "(confirming the shared build cache is fresh)".to_owned(),
8568                code: None,
8569                output_tail: e.to_string(),
8570                duration_ms: started.elapsed().as_millis() as u64,
8571                resource_blocked: true,
8572            }],
8573            false,
8574        );
8575    }
8576    let remaining = budget.saturating_sub(started.elapsed());
8577    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8578    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
8579    // `start_kill`); confirm it actually has before handing the directory to
8580    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
8581    // for what this can and cannot see.
8582    if !timed_out_pids.is_empty() {
8583        wait_for_timed_out_children_to_die(&timed_out_pids).await;
8584    }
8585    guard.release();
8586    (outcomes, retried)
8587}
8588
8589/// Poll `pids` — commands [`run_commands`] reports as still running when its
8590/// own timeout elapsed — until every one is confirmed gone, or
8591/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
8592///
8593/// Real confirmation where confirmation is possible, not a substitute for
8594/// full process-tree observation: a grandchild the timed-out process spawned
8595/// and that survives independently of it is invisible to a pid check the
8596/// same way it always was, and continuing to observe and collect *that*
8597/// stays a different piece of work with its own owner. This only narrows a
8598/// fixed blind wait into an actual check of the pids this process does know
8599/// about.
8600async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8601    wait_for_pids_with(
8602        pids,
8603        crate::proc::pid_alive,
8604        LEASE_RELEASE_POLL,
8605        LEASE_RELEASE_MAX_WAIT,
8606    )
8607    .await;
8608}
8609
8610/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
8611/// interval and ceiling supplied by the caller, so the polling *logic* -
8612/// returns as soon as every pid reports dead, gives up at the ceiling
8613/// otherwise - is testable on millisecond durations without asking the real
8614/// OS about a pid at all.
8615async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8616    pids: &[u32],
8617    alive: F,
8618    poll: Duration,
8619    max_wait: Duration,
8620) {
8621    let deadline = Instant::now() + max_wait;
8622    loop {
8623        if pids.iter().all(|&pid| !alive(pid)) {
8624            return;
8625        }
8626        if Instant::now() >= deadline {
8627            return;
8628        }
8629        tokio::time::sleep(poll).await;
8630    }
8631}
8632
8633/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
8634/// admission that it could not even get a verify command to run, as opposed
8635/// to evidence the command actually produced? A caller that would otherwise
8636/// read a resource-blocked outcome as a red command must check this first:
8637/// see [`Runner::gate`], which retries rather than records `Blocked` when
8638/// this is true.
8639fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8640    outcomes.iter().any(|o| o.resource_blocked)
8641}
8642
8643/// What [`Runner::gate_fix_round`] decided.
8644enum GateFix {
8645    /// The tree changed and `verify.e2e` is still green: run the gate again.
8646    Retry,
8647    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
8648    /// last failure stands and the run ends blocked.
8649    Stop,
8650    /// `verify.e2e` could not run after the fix (magi's own contention):
8651    /// decide nothing now, a later reentry retries.
8652    Defer,
8653}
8654
8655/// Is every red command in `outcomes` an ordinary failure the code could
8656/// explain: it ran, exited non-zero, and said something?
8657///
8658/// A timeout, a spawn failure and a killed process all leave `code` `None`;
8659/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
8660/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
8661/// what the command is stays the gate's business.
8662fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8663    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8664    red.peek().is_some()
8665        && red.all(|o| {
8666            !o.resource_blocked
8667                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8668                && !o.output_tail.trim().is_empty()
8669        })
8670}
8671
8672/// Describe one verify command's outcome for the event log, distinguishing a
8673/// build/link failure — the toolchain never produced a binary to run — from
8674/// an actual test failure, since only the latter is a verdict on the patch.
8675fn e2e_outcome_label(o: &CommandOutcome) -> String {
8676    if o.ok() {
8677        return "pass".to_owned();
8678    }
8679    let reason = if o.build_failed() {
8680        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8681    } else {
8682        format!("FAIL ({:?})", o.code)
8683    };
8684    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8685}
8686
8687/// Run `verify.e2e`, retrying once if the first attempt could not build or
8688/// link — a build/link failure is frequently a race against a shared
8689/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
8690/// `verify` event per command, tagged with `context` (normally `"round N"`)
8691/// so the two call sites that need this — the ordinary per-round leg in
8692/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
8693/// it will ever call a round green — read identically in the event log.
8694async fn run_e2e_with_retry(
8695    state: &mut RunState,
8696    shell: &[String],
8697    commands: &[String],
8698    worktree: &Path,
8699    timeout: Duration,
8700    context: &str,
8701) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8702    let (mut e2e, mut timed_out_pids) = run_commands(
8703        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8704    )
8705    .await;
8706    for o in &e2e {
8707        state.event(
8708            "verify",
8709            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8710        );
8711    }
8712    // A build/link failure is not a verdict on the patch — it is frequently a
8713    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
8714    // one retry before letting a red like that decide the round.
8715    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8716    if verify_retried {
8717        state.event(
8718            "verify",
8719            format!(
8720                "{context}: verify could not build/link, not a test result — retrying once \
8721                 before concluding"
8722            ),
8723        );
8724        let retried = run_commands(
8725            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8726        )
8727        .await;
8728        e2e = retried.0;
8729        // Both attempts' timeouts matter, not just the last one: the first
8730        // attempt's descendants may still be alive alongside the retry's.
8731        timed_out_pids.extend(retried.1);
8732        for o in &e2e {
8733            state.event(
8734                "verify",
8735                format!(
8736                    "{context}: retry `{}` -> {}",
8737                    o.command,
8738                    e2e_outcome_label(o)
8739                ),
8740            );
8741        }
8742    }
8743    (e2e, verify_retried, timed_out_pids)
8744}
8745
8746/// Run configured shell commands in `cwd`, in order. The second element is
8747/// the pid of every command that hit `timeout` and was still running when
8748/// this stopped waiting on it (best-effort: `None` when the platform did not
8749/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
8750/// that releases a shared resource afterward needs to know.
8751///
8752/// Records `task` into [`RunState::active`] at every command boundary
8753/// (`RunState::task_command`) and clears it once the whole list has run
8754/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
8755/// for minutes with no seat and no output of its own to show for it (see
8756/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
8757/// apart from "not yet run" without this), and this is the only place that
8758/// knows which command is running right now and how many are left. Three
8759/// saves per command — start, not per second — matching the same "only at a
8760/// boundary" rule [`wave`] already follows for seats.
8761#[allow(clippy::too_many_arguments)]
8762async fn run_commands(
8763    state: &mut RunState,
8764    node: &str,
8765    task: &str,
8766    attempt: usize,
8767    shell: &[String],
8768    commands: &[String],
8769    cwd: &Path,
8770    timeout: Duration,
8771) -> (Vec<CommandOutcome>, Vec<u32>) {
8772    if commands.is_empty() {
8773        // Nothing to mark as running and nothing to clear — an empty list
8774        // means "not configured", and touching `active` (or the disk) over
8775        // that would be a write for every round of a repo with no
8776        // `verify.e2e` / `verify.gate` commands at all.
8777        return (Vec::new(), Vec::new());
8778    }
8779    let mut out = Vec::new();
8780    let mut timed_out_pids = Vec::new();
8781    let total = commands.len();
8782    for (idx, command) in commands.iter().enumerate() {
8783        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8784        if let Err(e) = state.save() {
8785            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8786        }
8787        let started = Instant::now();
8788        let mut cmd = tokio::process::Command::new(&shell[0]);
8789        cmd.quiet();
8790        cmd.args(&shell[1..])
8791            .arg(command)
8792            .current_dir(cwd)
8793            .stdin(std::process::Stdio::null())
8794            .stdout(std::process::Stdio::piped())
8795            .stderr(std::process::Stdio::piped())
8796            .kill_on_drop(true);
8797        let spawned = cmd.spawn();
8798        let (code, body) = match spawned {
8799            Ok(child) => {
8800                // Captured before the child is consumed below: `kill_on_drop`
8801                // only *asks* the process to die when the timeout branch
8802                // drops it, and the pid is the only way anyone downstream can
8803                // later check whether that request actually took.
8804                let pid = child.id();
8805                match tokio::time::timeout(timeout, child.wait_with_output()).await {
8806                    Ok(Ok(o)) => {
8807                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8808                        body.push_str(&String::from_utf8_lossy(&o.stderr));
8809                        (o.status.code(), body)
8810                    }
8811                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
8812                    Err(_) => {
8813                        if let Some(pid) = pid {
8814                            timed_out_pids.push(pid);
8815                        }
8816                        (None, format!("timed out after {}s", timeout.as_secs()))
8817                    }
8818                }
8819            }
8820            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8821        };
8822        out.push(CommandOutcome {
8823            command: command.clone(),
8824            code,
8825            output_tail: tail(&body, OUTPUT_TAIL),
8826            duration_ms: started.elapsed().as_millis() as u64,
8827            resource_blocked: false,
8828        });
8829    }
8830    state.task_finished(task);
8831    if let Err(e) = state.save() {
8832        tracing::warn!("could not persist the end of {task}: {e:#}");
8833    }
8834    (out, timed_out_pids)
8835}
8836
8837/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
8838/// section (`report::run`) and in the `merge` event this node records — for
8839/// the operator to run by hand.
8840///
8841/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
8842/// branch whose ruleset forbids merge commits (GitHub's "must not contain
8843/// merge commits", or "require linear history") rejects the push a `--no-ff`
8844/// merge would produce, which is exactly the guidance this function replaces.
8845/// `message`'s first line becomes the squash commit's subject, matching the
8846/// note `report::run` prints alongside this command — see that function for
8847/// why an explicit subject is not optional there.
8848fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8849    let repo = repo.display();
8850    match style {
8851        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8852        MergeStyle::Squash => {
8853            // The subject sits inside double quotes, and a title an agent
8854            // wrote may carry the characters that break out of them.
8855            let subject = message
8856                .lines()
8857                .next()
8858                .unwrap_or(branch)
8859                .replace(['\\', '"', '$', '`'], "");
8860            format!(
8861                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8862            )
8863        }
8864        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8865    }
8866}
8867
8868/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
8869/// under the hood, rejects a `title` over 256 characters and the whole
8870/// command fails — no PR at all, for a run whose body was otherwise fine
8871/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
8872/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
8873/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
8874/// this bound. It is a margin, not a guarantee — a title packed
8875/// with multi-unit characters could still in principle land close to the
8876/// edge, but a real task title's occasional emoji or accented letter fits
8877/// comfortably inside it.
8878const PR_TITLE_MAX: usize = 240;
8879
8880/// A pull request title from the opening line of `text`, or `None` when that
8881/// line is not English (GitHub text is) or has no letters.
8882///
8883/// A line within `max` is kept as is. A longer one is cut at the end of its
8884/// first sentence when that falls inside `max`, else at a word boundary with a
8885/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
8886/// make every merely-truncated title look non-English). The language check
8887/// runs on the kept text before any mark is added, so only what GitHub will
8888/// show is judged: an English opening followed by non-ASCII far past the cut
8889/// still passes.
8890fn english_title(text: &str, max: usize) -> Option<String> {
8891    let line = queue::first_line(text)?;
8892    let chars: Vec<char> = line.chars().collect();
8893    let (kept, mark) = if chars.len() <= max {
8894        (line.to_owned(), "")
8895    } else if let Some(end) = sentence_end(&chars, max) {
8896        (chars[..end].iter().collect::<String>(), "")
8897    } else {
8898        let room = max.saturating_sub(3);
8899        // Cut at the last space inside the room; when the char just past the
8900        // room is a space the room already ends on a word.
8901        let cut = if chars[room].is_whitespace() {
8902            room
8903        } else {
8904            chars[..room]
8905                .iter()
8906                .rposition(|c| c.is_whitespace())
8907                .unwrap_or(room)
8908        };
8909        let head: String = chars[..cut].iter().collect();
8910        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8911        (head.to_owned(), "...")
8912    };
8913    if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8914        return None;
8915    }
8916    Some(format!("{kept}{mark}"))
8917}
8918
8919/// The char length of the first sentence of `chars` when it ends within `max`
8920/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
8921/// abbreviations so `e.g. foo` does not end a title early.
8922fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8923    const MIN: usize = 20;
8924    for i in MIN..max.min(chars.len()) {
8925        if !matches!(chars[i], '.' | '!' | '?') {
8926            continue;
8927        }
8928        let Some(&next) = chars.get(i + 1) else {
8929            continue;
8930        };
8931        if !next.is_whitespace() {
8932            continue;
8933        }
8934        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8935        if after.is_some_and(|c| c.is_ascii_lowercase()) {
8936            continue;
8937        }
8938        let word: String = chars[..i]
8939            .iter()
8940            .rev()
8941            .take_while(|c| !c.is_whitespace())
8942            .collect::<Vec<_>>()
8943            .into_iter()
8944            .rev()
8945            .collect();
8946        let word = word.to_ascii_lowercase();
8947        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8948            continue;
8949        }
8950        let end = chars[..i]
8951            .iter()
8952            .rposition(|c| !c.is_whitespace())
8953            .map_or(i, |p| p + 1);
8954        return Some(end);
8955    }
8956    None
8957}
8958
8959/// What `merge = "pr"` (and the merge commit of the other modes) says about a
8960/// change: a title and a body describing what was *implemented*, not the task
8961/// that asked for it. A task reads as a request; a reader of the merged
8962/// history wants the change.
8963struct PrMessage {
8964    title: String,
8965    body: String,
8966}
8967
8968impl PrMessage {
8969    /// Title, blank line, body. The first line is the squash/merge commit
8970    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
8971    /// has to stay one sensible line.
8972    fn commit_message(&self) -> String {
8973        format!("{}\n\n{}", self.title, self.body)
8974    }
8975}
8976
8977/// The text after a leading `TITLE:` (any case) on `line`.
8978fn title_marker(line: &str) -> Option<&str> {
8979    let line = line.trim();
8980    let head = line.get(..6)?;
8981    head.eq_ignore_ascii_case("title:")
8982        .then(|| line[6..].trim())
8983}
8984
8985/// The implementer's own one-line title: the `TITLE:` line the implement
8986/// prompt asks for at the top of its SUMMARY. Candidate commits are all
8987/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
8988/// source, and a title that says as much is refused here too.
8989fn summary_title(summary: &str) -> Option<String> {
8990    let first = summary.lines().find(|l| !l.trim().is_empty())?;
8991    let raw = title_marker(first)?;
8992    if raw.is_empty() {
8993        return None;
8994    }
8995    let title = queue::title_from(raw, PR_TITLE_MAX);
8996    let lower = title.to_ascii_lowercase();
8997    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8998        return None;
8999    }
9000    Some(title)
9001}
9002
9003/// How `open_review`'s instruction begins; see [`landing_title`].
9004const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
9005
9006/// Marker `open_review` gives a candidate that nothing in the roster wrote.
9007const EXISTING_BRANCH: &str = "(existing branch)";
9008
9009/// Does this run review work that already existed, rather than implement a
9010/// task? Runs recorded before `reviewed_commits` existed carry only the
9011/// candidate marker.
9012fn is_review_run(state: &RunState) -> bool {
9013    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
9014}
9015
9016/// The title of a review-only run: the subject of the oldest commit under
9017/// review. Later commits are usually fixups, and `instruction` is the review
9018/// prompt, which says nothing about the change. GitHub text is English, so a
9019/// non-ASCII or blank subject yields `None` and the caller's neutral title.
9020fn review_title(state: &RunState) -> Option<String> {
9021    english_subject(state.reviewed_commits.as_ref()?.first()?)
9022}
9023
9024/// `raw` as a pull request title, or `None` when it is blank, not English
9025/// (GitHub text is), or one of magi's own candidate commit subjects.
9026fn english_subject(raw: &str) -> Option<String> {
9027    let raw = raw.trim();
9028    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
9029        return None;
9030    }
9031    let title = queue::title_from(raw, PR_TITLE_MAX);
9032    let lower = title.to_ascii_lowercase();
9033    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
9034        return None;
9035    }
9036    Some(title)
9037}
9038
9039/// What a review-only run's branch says about itself, read at the moment the
9040/// pull request is opened.
9041#[derive(Debug, Clone, PartialEq, Eq)]
9042struct BranchFacts {
9043    /// `(subject, body)` of each commit, oldest first.
9044    commits: Vec<(String, String)>,
9045    /// Trimmed `git diff --stat`.
9046    stat: String,
9047}
9048
9049/// Longest diff stat shown: this many file lines plus the summary line.
9050const STAT_FILE_LINES: usize = 25;
9051/// Cap on the commit list, well inside GitHub's 65536-character body limit.
9052const COMMITS_MAX_CHARS: usize = 20_000;
9053
9054/// Read the commits and diff stat of `base..branch`. `None` when git cannot
9055/// say or finds nothing, so the caller falls back to what the run recorded.
9056async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
9057    let commits = git::commit_log(repo, base, branch).await.ok()?;
9058    if commits.is_empty() {
9059        return None;
9060    }
9061    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
9062    let lines: Vec<&str> = stat.lines().collect();
9063    let stat = if lines.len() > STAT_FILE_LINES + 1 {
9064        let omitted = lines.len() - 1 - STAT_FILE_LINES;
9065        let more = format!(" ... {omitted} more file(s)");
9066        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
9067        kept.push(&more);
9068        kept.push(lines[lines.len() - 1]);
9069        kept.join("\n")
9070    } else {
9071        lines.join("\n")
9072    };
9073    Some(BranchFacts { commits, stat })
9074}
9075
9076/// Defang what would break the surrounding markdown: a closing `</details>`
9077/// and a code fence.
9078fn markdown_safe(text: &str) -> String {
9079    text.replace("</details>", "&lt;/details&gt;")
9080        .replace("\x60\x60\x60", "~~~")
9081}
9082
9083fn neutral_title(state: &RunState, winner: char) -> String {
9084    format!(
9085        "chore: land candidate {} of run {}",
9086        winner.to_ascii_uppercase(),
9087        state.id
9088    )
9089}
9090
9091/// The pull request title to hand to `land::merge_subject`. A review-only run
9092/// opened by an earlier build titled its pull request with the review prompt;
9093/// that title is dropped (empty, so the fallback applies) rather than landed.
9094/// Any other title, including an operator's rename, passes through untouched,
9095/// and so does every title of a run that implements a task.
9096pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
9097    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
9098        ""
9099    } else {
9100        pr_title
9101    }
9102}
9103
9104/// What the squash subject falls back to when the pull request title is empty
9105/// or candidate-shaped: for a review-only run the derived title, never the
9106/// review prompt held in `instruction`.
9107pub fn landing_subject_source(state: &RunState) -> String {
9108    if is_review_run(state) {
9109        let winner = state.candidates.first().map_or('A', |c| c.label);
9110        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
9111    }
9112    state.instruction.clone()
9113}
9114
9115/// `summary` without its `TITLE:` line, which the pull request title already
9116/// carries.
9117fn summary_without_title(summary: &str) -> String {
9118    let mut lines = summary.trim().lines().peekable();
9119    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
9120        lines.next();
9121    }
9122    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
9123}
9124
9125/// The pull request title and body for the winning candidate.
9126///
9127/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
9128/// the task's own opening line via [`queue::title_from`] when there is none.
9129/// `state.instruction` can open with blank lines (`task_text` only rejects a
9130/// body that is blank *entirely*), which `title_from` skips.
9131///
9132/// Body: the implementer's summary and the fixer's notes, then — when the
9133/// winning review round was not clean — the findings still open and whatever
9134/// the fixer declined, so `merge = "pr"` hands the reader the same material
9135/// `magi show` does. The task follows inside a collapsed block, and the
9136/// footer repeats the run and candidate as plain tags for a reader holding
9137/// only the merged commit or the PR body.
9138#[cfg(test)]
9139fn pr_message(state: &RunState, winner: char) -> PrMessage {
9140    pr_message_with(state, winner, None)
9141}
9142
9143/// [`pr_message`] with what the branch of a review-only run says about itself.
9144/// `facts` is ignored for a run that implements a task.
9145#[cfg(test)]
9146fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9147    let raw = pr_message_raw(state, winner, facts);
9148    let id = crate::scrub::Identity::current();
9149    PrMessage {
9150        title: crate::scrub::scrub(&raw.title, &id),
9151        body: crate::scrub::scrub(&raw.body, &id),
9152    }
9153}
9154
9155fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
9156    let summary = state
9157        .candidates
9158        .iter()
9159        .find(|c| c.label == winner)
9160        .map(|c| c.summary.as_str())
9161        .unwrap_or_default();
9162    // The fallback is the operator's own words and may not be English; GitHub
9163    // text always is, so a non-English task gets a neutral title instead.
9164    let review = is_review_run(state);
9165    let title = if review {
9166        facts
9167            .and_then(|f| english_subject(&f.commits.first()?.0))
9168            .or_else(|| review_title(state))
9169            .or_else(|| {
9170                state
9171                    .candidates
9172                    .iter()
9173                    .find(|c| c.label == winner)
9174                    .filter(|c| !c.branch.starts_with("magi/"))
9175                    .and_then(|c| english_subject(&c.branch))
9176            })
9177            .unwrap_or_else(|| neutral_title(state, winner))
9178    } else {
9179        summary_title(summary).unwrap_or_else(|| {
9180            english_title(&state.instruction, PR_TITLE_MAX)
9181                .unwrap_or_else(|| neutral_title(state, winner))
9182        })
9183    };
9184
9185    let mut body = String::new();
9186    let what = summary_without_title(summary);
9187    if !what.is_empty() {
9188        body.push_str("## Summary\n\n");
9189        body.push_str(&what);
9190        body.push_str("\n\n");
9191    }
9192
9193    // The last round is usually a clean verification pass with no fix of its
9194    // own, so every round's notes are read, not just the final one's.
9195    let notes: Vec<(usize, &str)> = state
9196        .reviews
9197        .iter()
9198        .filter_map(|r| {
9199            let n = r.fix.as_ref()?.notes.trim();
9200            (!n.is_empty()).then_some((r.round, n))
9201        })
9202        .collect();
9203    if !notes.is_empty() {
9204        body.push_str("## Review fixes\n\n");
9205        if let [(_, only)] = notes.as_slice() {
9206            body.push_str(only);
9207            body.push_str("\n\n");
9208        } else {
9209            for (round, n) in &notes {
9210                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
9211            }
9212        }
9213    }
9214    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
9215
9216    let open = state.open_findings();
9217    if !open.is_empty() {
9218        body.push_str("## Open review findings\n\n");
9219        for f in &open {
9220            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
9221        }
9222        body.push('\n');
9223    }
9224
9225    if let Some(fix) = fix
9226        && !fix.rejected.is_empty()
9227    {
9228        body.push_str("## Declined by the fixer\n\n");
9229        for r in &fix.rejected {
9230            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
9231        }
9232        body.push('\n');
9233    }
9234
9235    if review {
9236        // The review prompt is not the task; list what the branch carries.
9237        body.push_str("## Commits under review\n\n");
9238        if let Some(facts) = facts {
9239            let mut left = COMMITS_MAX_CHARS;
9240            for (i, (subject, text)) in facts.commits.iter().enumerate() {
9241                let mut entry = format!("- {}\n", markdown_safe(subject));
9242                for l in markdown_safe(text).lines() {
9243                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
9244                }
9245                if left == 0 {
9246                    body.push_str(&format!(
9247                        "- ... {} more commit(s)\n",
9248                        facts.commits.len() - i
9249                    ));
9250                    break;
9251                }
9252                if entry.len() > left {
9253                    // Even the first commit is cut: one huge body must not
9254                    // push the whole description past GitHub's limit.
9255                    let mut end = left;
9256                    while !entry.is_char_boundary(end) {
9257                        end -= 1;
9258                    }
9259                    entry.truncate(end);
9260                    entry.push_str("\n  ... (truncated)\n");
9261                    left = 0;
9262                } else {
9263                    left -= entry.len();
9264                }
9265                body.push_str(&entry);
9266            }
9267            if !facts.stat.trim().is_empty() {
9268                body.push_str(&format!(
9269                    "\n## Diff stat\n\n```\n{}\n```\n",
9270                    markdown_safe(facts.stat.trim())
9271                ));
9272            }
9273        } else {
9274            match &state.reviewed_commits {
9275                Some(subjects) => {
9276                    for s in subjects {
9277                        body.push_str(&format!("- {}\n", s.trim()));
9278                    }
9279                }
9280                None => {
9281                    // An older run kept only the prompt, with the commit list
9282                    // after its first paragraph.
9283                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
9284                    body.push_str(rest.trim());
9285                    body.push('\n');
9286                }
9287            }
9288        }
9289    } else {
9290        let task = state.instruction.trim();
9291        let task = if task.is_empty() {
9292            "(empty task)"
9293        } else {
9294            task
9295        };
9296        body.push_str(&format!(
9297            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
9298            task.replace("</details>", "&lt;/details&gt;")
9299        ));
9300    }
9301
9302    body.push_str(&format!(
9303        "\n---\nmagi:run/{} magi:candidate-{}\n",
9304        state.id,
9305        winner.to_ascii_lowercase()
9306    ));
9307
9308    PrMessage { title, body }
9309}
9310
9311/// The task with what the repository says about the existing work it names
9312/// appended, so an implementer knows what it started from and what it must
9313/// not redo. Unchanged when the task names nothing.
9314fn seeded_instruction(state: &RunState) -> String {
9315    match refs::describe(&state.seeds) {
9316        Some(facts) => format!(
9317            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
9318             Candidates start from the unmerged branch named above, when there \
9319             is one, and carry any unmerged commit named by sha as a \
9320             cherry-pick. Check that this is what the task meant before \
9321             building on it.",
9322            state.instruction
9323        ),
9324        None => state.instruction.clone(),
9325    }
9326}
9327
9328/// Does the winner have no commits ahead of the base it would land on?
9329/// Any failure to find out reads as "not empty": the merge then behaves as it
9330/// always did rather than refusing on a guess.
9331async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
9332    let base = &state.base_branch;
9333    let mut against = base.clone();
9334    if mode == MergeMode::Pr {
9335        // A pull request lands on the remote's base, never the local branch
9336        // of the same name: if that cannot be read, "not empty" is the safe
9337        // answer.
9338        let remote = &state.config.merge.remote;
9339        let tracking = format!("{remote}/{base}");
9340        let fetched = git::fetch(repo, remote, base).await;
9341        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
9342            against = tracking;
9343        } else {
9344            return false;
9345        }
9346    }
9347    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
9348}
9349
9350/// Why nothing was opened for an empty winner, with what the task's own
9351/// references resolved to.
9352fn empty_candidate_detail(state: &RunState, base: &str) -> String {
9353    let mut detail = format!(
9354        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
9355         nothing to open a pull request for"
9356    );
9357    match refs::describe(&state.seeds) {
9358        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
9359        None => detail.push_str(
9360            "\nThe task names no existing branch or commit; if it means to land work \
9361             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
9362        ),
9363    }
9364    detail
9365}
9366
9367/// What the `Pr` merge does once it knows whether the branch already has an
9368/// open pull request.
9369#[derive(Debug, PartialEq, Eq)]
9370enum PrPlan {
9371    Create,
9372    Adopt { url: String, title: String },
9373    Stop(String),
9374}
9375
9376/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
9377/// a failed lookup -> stop with the real reason. Never guesses.
9378fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
9379    match found {
9380        Ok(land::OpenPr::None) => PrPlan::Create,
9381        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
9382        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
9383            "several open pull requests exist for this branch, not picking one: {}",
9384            urls.join(" ")
9385        )),
9386        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
9387    }
9388}
9389
9390/// `gh pr create`, returning the PR url.
9391pub(crate) async fn gh_pr_create(
9392    cwd: &Path,
9393    base: &str,
9394    head: &str,
9395    title: &str,
9396    body: &str,
9397) -> Result<String> {
9398    let out = tokio::process::Command::new("gh")
9399        .args([
9400            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
9401        ])
9402        .current_dir(cwd)
9403        .quiet()
9404        .stdin(std::process::Stdio::null())
9405        .output()
9406        .await
9407        .context("spawn gh")?;
9408    if out.status.success() {
9409        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
9410    } else {
9411        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
9412    }
9413}
9414
9415/// Tear a run's worktrees and branches down.
9416///
9417/// `home` is where the updated `run.json` is saved (via
9418/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
9419/// a housekeeping pass already has its own honest `home` handed to it, and
9420/// falling through to the global here would write back through whichever
9421/// directory some other process or test pinned into that `OnceLock` first,
9422/// not the one the caller actually resolved its `runs` and `state` from.
9423pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
9424    let repo = state.repo.clone();
9425    let root = state.worktree_root();
9426    let winner = state.tally.as_ref().map(|t| t.winner);
9427    let mut removed = Vec::new();
9428
9429    for i in 0..state.candidates.len() {
9430        let c = state.candidates[i].clone();
9431        let is_winner = Some(c.label) == winner;
9432        if is_winner && !drop_winner {
9433            continue;
9434        }
9435        if c.worktree.exists() {
9436            git::worktree_remove(&repo, &c.worktree).await.ok();
9437            removed.push(c.worktree.to_string_lossy().into_owned());
9438        }
9439        // A branch handed to a later run (and its pull request) is not this
9440        // run's to delete.
9441        let handed_over = state.released_branches.contains(&c.branch);
9442        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
9443            git::branch_delete(&repo, &c.branch).await.ok();
9444            removed.push(c.branch.clone());
9445        }
9446        state.candidates[i].folded = true;
9447    }
9448
9449    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
9450        let path = name.path();
9451        let keep = !drop_winner
9452            && winner.is_some_and(|w| {
9453                path.file_name()
9454                    .is_some_and(|n| n == format!("cand-{w}").as_str())
9455            });
9456        if keep {
9457            continue;
9458        }
9459        git::worktree_remove(&repo, &path).await.ok();
9460        removed.push(path.to_string_lossy().into_owned());
9461    }
9462
9463    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
9464    // judge worktrees, so once the loop above has cleared all of them out,
9465    // the parent is a bare directory nobody else was ever going to remove -
9466    // git only ever managed what was inside it. Left alone, one of these
9467    // accumulates per fully-folded run; the operator's own machine had 74.
9468    // `remove_if_empty` re-checks rather than assuming: a run whose winner
9469    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
9470    // so does anything a run never claimed that happens to share the bay.
9471    remove_if_empty(&root);
9472
9473    if state.enabled_worktree_config && drop_winner {
9474        // A release, not a raw disable: some sibling run in this repository
9475        // may still hold its own reference (see `git::acquire_worktree_config`),
9476        // and only the last release actually turns the setting back off.
9477        git::release_worktree_config(&repo).await.ok();
9478        state.enabled_worktree_config = false;
9479    }
9480    state.save_under(home)?;
9481    Ok(removed)
9482}
9483
9484/// Remove `dir` if it exists and has nothing in it.
9485///
9486/// Best-effort and silent by design: a directory that is not empty (a run
9487/// whose winner is still parked there, a stray file some other process left)
9488/// is exactly the case this must refuse, and a directory that is already gone
9489/// is not a failure worth reporting either. `std::fs::remove_dir` itself
9490/// already refuses a non-empty directory, so the emptiness check below is
9491/// belt, not suspenders - it is what keeps this from ever attempting the
9492/// removal in the case that matters, rather than trusting `remove_dir`'s
9493/// error path to have no side effects if it ever changed.
9494fn remove_if_empty(dir: &Path) {
9495    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9496        std::fs::remove_dir(dir).ok();
9497    }
9498}
9499
9500/// Severity of the worst open finding in the last review round, for reporting.
9501pub fn worst_open(state: &RunState) -> Option<Severity> {
9502    state
9503        .reviews
9504        .last()?
9505        .reviews
9506        .iter()
9507        .flat_map(|r| r.findings.iter())
9508        .map(|f| f.severity)
9509        .max()
9510}
9511
9512/// `[worktree] setup` hides its products per worktree, which needs
9513/// `extensions.worktreeConfig`. Taken once per run and released with the run's
9514/// worktrees, like the hook's; every place that runs setup calls this first.
9515async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9516    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9517        git::acquire_worktree_config(repo).await?;
9518        state.enabled_worktree_config = true;
9519    }
9520    Ok(())
9521}
9522
9523#[cfg(test)]
9524mod tests {
9525    #[test]
9526    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9527        let leaked = vec!["chore(deps): update a crate".to_owned()];
9528        let own = "fix(daemon): apply a chosen action";
9529        assert!(should_retitle("", own, &leaked));
9530        assert!(should_retitle(
9531            &format!("{REVIEW_PROMPT_OPENING} `x`"),
9532            own,
9533            &leaked
9534        ));
9535        assert!(should_retitle(
9536            "chore: land candidate A of run 1",
9537            own,
9538            &leaked
9539        ));
9540        assert!(should_retitle(
9541            "magi: candidate A (uncommitted work)",
9542            own,
9543            &leaked
9544        ));
9545        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9546        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9547        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9548    }
9549
9550    #[test]
9551    fn pr_merge_plan_creates_adopts_or_stops() {
9552        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9553        assert_eq!(
9554            pr_merge_plan(Ok(land::OpenPr::One {
9555                url: "u".into(),
9556                title: "t".into()
9557            })),
9558            PrPlan::Adopt {
9559                url: "u".into(),
9560                title: "t".into()
9561            }
9562        );
9563        let PrPlan::Stop(many) =
9564            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9565        else {
9566            panic!("many must stop");
9567        };
9568        assert!(many.contains('a') && many.contains('b'));
9569        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9570            panic!("a failed lookup must stop");
9571        };
9572        assert!(err.contains("bad token"));
9573    }
9574
9575    use super::*;
9576    use crate::run::GateStatus;
9577    use std::collections::BTreeMap;
9578    use std::time::Duration;
9579
9580    fn conductor() -> AgentSpec {
9581        AgentSpec {
9582            id: "conductor".to_owned(),
9583            kind: crate::config::AgentKind::Command,
9584            model: None,
9585            command: vec!["true".to_owned()],
9586            extra_args: Vec::new(),
9587            env: BTreeMap::new(),
9588            prompt_delivery: None,
9589        }
9590    }
9591
9592    fn spec(id: &str) -> AgentSpec {
9593        AgentSpec {
9594            id: id.to_owned(),
9595            kind: crate::config::AgentKind::Command,
9596            model: None,
9597            command: vec!["true".to_owned()],
9598            extra_args: Vec::new(),
9599            env: BTreeMap::new(),
9600            prompt_delivery: None,
9601        }
9602    }
9603
9604    fn ids(xs: &[&str]) -> BTreeSet<String> {
9605        xs.iter().map(|s| (*s).to_owned()).collect()
9606    }
9607
9608    #[test]
9609    fn pick_successor_skips_an_agent_another_seat_holds() {
9610        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
9611        let roster = [spec("a"), spec("b"), spec("c")];
9612        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9613        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9614    }
9615
9616    #[test]
9617    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9618        // The other seat started on c but was handed to d; c is free again.
9619        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9620        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9621        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9622        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9623        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9624    }
9625
9626    #[test]
9627    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9628        let roster = [spec("a"), spec("b")];
9629        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9630        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9631        let carried = ids(&["b"]);
9632        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9633        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9634    }
9635
9636    #[test]
9637    fn pick_successor_returns_none_without_any_untried_successor() {
9638        let roster = [spec("a"), spec("b")];
9639        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9640        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9641        let carried = ids(&["a"]);
9642        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9643    }
9644
9645    #[test]
9646    fn pick_successor_rescue_avoids_another_seats_occupant() {
9647        // b is a carried failure and free; a is held by the other seat.
9648        let roster = [spec("a"), spec("b"), spec("c")];
9649        let carried = ids(&["b", "c"]);
9650        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9651        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9652    }
9653
9654    #[test]
9655    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9656        let roster = [spec("a"), spec("b"), spec("c")];
9657        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9658        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9659    }
9660
9661    #[test]
9662    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9663        let roster = [spec("a"), spec("b"), spec("c")];
9664        let failed = ids(&["a", "b", "c"]);
9665        // Rescue looks at the whole roster, once per id, then runs out.
9666        let mut tried = ids(&["b"]);
9667        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9668        assert_eq!(first.id, "a");
9669        tried.insert(first.id.clone());
9670        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9671        assert_eq!(second.id, "c");
9672        tried.insert(second.id.clone());
9673        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9674    }
9675
9676    #[test]
9677    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9678        let roster = [spec("a"), spec("b")];
9679        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9680        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9681    }
9682
9683    #[test]
9684    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9685        let roster = [spec("a"), spec("b"), spec("c")];
9686        let h = SeatHistory {
9687            failed: ids(&["a"]),
9688            last_ok: Some("b".to_owned()),
9689            last_fail: None,
9690        };
9691        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9692        // A last answerer that left the roster, or later failed, is ignored.
9693        let gone = SeatHistory {
9694            last_ok: Some("zzz".to_owned()),
9695            ..h.clone()
9696        };
9697        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9698        let failed = SeatHistory {
9699            failed: ids(&["a", "b"]),
9700            last_ok: Some("b".to_owned()),
9701            last_fail: None,
9702        };
9703        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9704    }
9705
9706    #[test]
9707    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9708        let first = SeatState::new("review-1", "alpha", 7);
9709        let next = handover_seat("review-1", "gamma", 7);
9710        assert_ne!(first.claude_session, next.claude_session);
9711    }
9712
9713    #[test]
9714    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9715        let mut state = state_with_summary("x", "y");
9716        let a = handover_seat("review-1", "beta", state.next_seat_seed());
9717        let b = handover_seat("review-1", "beta", state.next_seat_seed());
9718        assert_ne!(a.claude_session, b.claude_session);
9719    }
9720
9721    #[test]
9722    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9723        let roster = [spec("a"), spec("b")];
9724        let h = SeatHistory {
9725            failed: ids(&["a", "b"]),
9726            ..SeatHistory::default()
9727        };
9728        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9729        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9730        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9731    }
9732
9733    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
9734    // fallback loop depends on to terminate: it must walk forward from the
9735    // seat's own position, never restart at the front of the roster, and it
9736    // must never hand back an id already tried, however many times that id
9737    // happens to appear.
9738
9739    #[test]
9740    fn failure_signature_ignores_numbers_and_paths() {
9741        assert_eq!(
9742            failure_signature("exited with Some(2) and no usable output"),
9743            failure_signature("exited with Some(137) and no usable output")
9744        );
9745        assert_eq!(
9746            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9747            failure_signature("cannot open /var/x.txt: denied")
9748        );
9749        assert_ne!(failure_signature("boom"), failure_signature("bang"));
9750    }
9751
9752    #[test]
9753    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9754        let other = FailClass::Other("x".into());
9755        assert!(should_hand_over(None, &FailClass::Quota));
9756        assert!(should_hand_over(Some(&other), &FailClass::Quota));
9757        assert!(should_hand_over(
9758            Some(&FailClass::Timeout),
9759            &FailClass::Timeout
9760        ));
9761        assert!(should_hand_over(None, &other));
9762        assert!(!should_hand_over(Some(&other), &other));
9763        assert!(should_hand_over(
9764            Some(&other),
9765            &FailClass::Other("y".into())
9766        ));
9767        // A quota or timeout in between ends the run of identical failures.
9768        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9769        assert!(should_hand_over(Some(&FailClass::Quota), &other));
9770    }
9771
9772    #[test]
9773    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9774        let a = SeatState::new("judge-1", "alpha", 7);
9775        let b = handover_seat("judge-1", "beta", 7);
9776        assert_ne!(a.claude_session, b.claude_session);
9777        assert_eq!(b.turns, 0);
9778    }
9779
9780    #[test]
9781    fn a_timeout_is_classified_apart_from_other_failures() {
9782        assert_eq!(
9783            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9784            Some(FailClass::Timeout)
9785        );
9786        assert!(matches!(
9787            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9788            Some(FailClass::Other(_))
9789        ));
9790    }
9791
9792    #[test]
9793    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9794        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9795        let tried = BTreeSet::from(["beta".to_owned()]);
9796        // beta sits at index 1; the next candidate is gamma, never alpha —
9797        // which is very likely a different candidate slot's own agent.
9798        let next = next_untried_in_roster(&roster, 1, &tried);
9799        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9800    }
9801
9802    #[test]
9803    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9804        let roster = vec![spec("alpha"), spec("beta")];
9805        let tried = BTreeSet::from(["beta".to_owned()]);
9806        // beta is the roster's last entry: nothing follows it, and alpha —
9807        // earlier in the roster, almost certainly a different candidate
9808        // slot's own agent — must not be reached by wrapping back to it.
9809        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9810    }
9811
9812    #[test]
9813    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9814        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9815        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9816        // beta (index 1) and gamma (index 2, the only entry after it) have
9817        // both been tried; alpha (index 0) never has, but it comes before
9818        // beta's own position, so there is nothing further for this seat.
9819        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9820    }
9821
9822    #[test]
9823    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9824        let roster = vec![spec("a"), spec("a"), spec("b")];
9825        let tried = BTreeSet::from(["a".to_owned()]);
9826        let next = next_untried_in_roster(&roster, 0, &tried);
9827        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9828    }
9829
9830    #[test]
9831    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9832        let roster = vec![spec("a"), spec("b")];
9833        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9834        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9835    }
9836
9837    #[test]
9838    fn remove_if_empty_only_ever_takes_a_bare_directory() {
9839        let dir = tempfile::tempdir().unwrap();
9840        let bay = dir.path().join("ffff");
9841
9842        // Not there yet: nothing to do, nothing to panic on.
9843        remove_if_empty(&bay);
9844        assert!(!bay.exists());
9845
9846        // Something still inside - the winner's worktree, or a stray file -
9847        // keeps the directory standing.
9848        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9849        remove_if_empty(&bay);
9850        assert!(bay.exists(), "non-empty directory must survive");
9851
9852        // Once the last entry is gone, so is the directory itself.
9853        std::fs::remove_dir(bay.join("cand-A")).unwrap();
9854        remove_if_empty(&bay);
9855        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9856    }
9857
9858    // `round_is_clean` is the exact decision this task fixed: a round with a
9859    // seat that never answered must not read the same as a round every seat
9860    // actually reviewed. These are deterministic and process-free by design —
9861    // the equivalent end-to-end check (a real reviewer timing out under a
9862    // live graph run) is a genuine race against wall-clock contention, and a
9863    // spawn slow enough to blow even a generous budget under a loaded test
9864    // run must not turn this specific regression check flaky.
9865
9866    #[test]
9867    fn a_full_panel_that_found_nothing_is_clean() {
9868        assert!(round_is_clean(
9869            0,
9870            true,
9871            2,
9872            2,
9873            0,
9874            IncompleteReviewPolicy::Block
9875        ));
9876    }
9877
9878    #[test]
9879    fn a_missing_seat_is_never_clean_under_the_default_policy() {
9880        assert!(!round_is_clean(
9881            0,
9882            true,
9883            1,
9884            2,
9885            0,
9886            IncompleteReviewPolicy::Block
9887        ));
9888    }
9889
9890    #[test]
9891    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9892        assert!(!round_is_clean(
9893            1,
9894            true,
9895            1,
9896            2,
9897            0,
9898            IncompleteReviewPolicy::Warn
9899        ));
9900    }
9901
9902    #[test]
9903    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9904        assert!(round_is_clean(
9905            0,
9906            true,
9907            1,
9908            2,
9909            0,
9910            IncompleteReviewPolicy::Warn
9911        ));
9912    }
9913
9914    #[test]
9915    fn a_full_panel_with_an_open_finding_is_not_clean() {
9916        assert!(!round_is_clean(
9917            1,
9918            true,
9919            2,
9920            2,
9921            0,
9922            IncompleteReviewPolicy::Block
9923        ));
9924    }
9925
9926    #[test]
9927    fn a_full_panel_with_a_red_e2e_is_not_clean() {
9928        assert!(!round_is_clean(
9929            0,
9930            false,
9931            2,
9932            2,
9933            0,
9934            IncompleteReviewPolicy::Block
9935        ));
9936    }
9937
9938    // The stall this task closes: under the default `block` policy, a seat
9939    // missing only because it was rate limited must not force a wait for a
9940    // session limit that will not lift by the next round. `round_is_clean`
9941    // is where that quorum carve-out lives; the review loop around it never
9942    // changes what a reviewer's vote or a finding's severity means.
9943
9944    #[test]
9945    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9946        // 1 of 2 answered, and the one missing was quota'd — the exact
9947        // "review-2 rate limited (quota)" shape from the field report.
9948        assert!(round_is_clean(
9949            0,
9950            true,
9951            1,
9952            2,
9953            1,
9954            IncompleteReviewPolicy::Block
9955        ));
9956    }
9957
9958    #[test]
9959    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9960        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
9961        // not a quota loss (`quota_missing` stays 0) — worth another try.
9962        assert!(!round_is_clean(
9963            0,
9964            true,
9965            1,
9966            2,
9967            0,
9968            IncompleteReviewPolicy::Block
9969        ));
9970    }
9971
9972    #[test]
9973    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9974        assert!(!round_is_clean(
9975            1,
9976            true,
9977            1,
9978            2,
9979            1,
9980            IncompleteReviewPolicy::Block
9981        ));
9982        assert!(!round_is_clean(
9983            0,
9984            false,
9985            1,
9986            2,
9987            1,
9988            IncompleteReviewPolicy::Block
9989        ));
9990    }
9991
9992    #[test]
9993    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9994        // Every seat quota'd, nobody answered: there is no panel to decide
9995        // on, so this must fall through to the existing block-and-retry
9996        // fallback rather than call an unreviewed patch clean.
9997        assert!(!round_is_clean(
9998            0,
9999            true,
10000            0,
10001            2,
10002            2,
10003            IncompleteReviewPolicy::Block
10004        ));
10005    }
10006
10007    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
10008        CommandOutcome {
10009            command: "test".to_owned(),
10010            code,
10011            output_tail: String::new(),
10012            duration_ms: 0,
10013            resource_blocked,
10014        }
10015    }
10016
10017    #[test]
10018    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
10019        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
10020        assert!(
10021            !verify_inconclusive(&[outcome(Some(1), false)]),
10022            "an ordinary failure is still evidence about the patch"
10023        );
10024        assert!(verify_inconclusive(&[outcome(None, true)]));
10025        assert!(
10026            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
10027            "one inconclusive outcome taints the whole batch"
10028        );
10029        assert!(!verify_inconclusive(&[]));
10030    }
10031
10032    #[tokio::test]
10033    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
10034        // Alive for the first two checks, then dead - confirms the loop
10035        // actually re-polls rather than deciding once and sleeping out the
10036        // ceiling regardless.
10037        let calls = std::sync::atomic::AtomicUsize::new(0);
10038        let started = Instant::now();
10039        wait_for_pids_with(
10040            &[123],
10041            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
10042            Duration::from_millis(5),
10043            Duration::from_secs(5),
10044        )
10045        .await;
10046        assert!(
10047            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
10048            "must keep checking rather than deciding on the first answer"
10049        );
10050        assert!(
10051            started.elapsed() < Duration::from_secs(1),
10052            "must return the moment it is confirmed dead, not wait out the ceiling"
10053        );
10054    }
10055
10056    #[tokio::test]
10057    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
10058        let started = Instant::now();
10059        wait_for_pids_with(
10060            &[123],
10061            |_| true, // never reports dead
10062            Duration::from_millis(5),
10063            Duration::from_millis(30),
10064        )
10065        .await;
10066        let elapsed = started.elapsed();
10067        assert!(
10068            elapsed >= Duration::from_millis(30),
10069            "must not give up before its own ceiling: {elapsed:?}"
10070        );
10071        assert!(
10072            elapsed < Duration::from_secs(1),
10073            "must not wait past its own ceiling either: {elapsed:?}"
10074        );
10075    }
10076
10077    #[tokio::test]
10078    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
10079        let started = Instant::now();
10080        wait_for_pids_with(
10081            &[],
10082            |_| true,
10083            Duration::from_secs(5),
10084            Duration::from_secs(5),
10085        )
10086        .await;
10087        assert!(
10088            started.elapsed() < Duration::from_millis(200),
10089            "an empty pid list has nothing to confirm"
10090        );
10091    }
10092
10093    // `review_conclusion` is the exact decision the review hand-off task
10094    // fixed: a round budget spent (or a tree that stopped moving) must not
10095    // collapse into `Blocked` regardless of what verification actually
10096    // said. Deterministic and process-free for the same reason the
10097    // `round_is_clean` family above is.
10098    fn review_round(
10099        clean: bool,
10100        blocking: usize,
10101        answered: usize,
10102        expected: usize,
10103        progressed: bool,
10104        e2e_ok: bool,
10105    ) -> ReviewRound {
10106        ReviewRound {
10107            round: 1,
10108            head: "h".to_owned(),
10109            verified_head: None,
10110            verified_at: None,
10111            reviews: Vec::new(),
10112            e2e: vec![CommandOutcome {
10113                command: "test".to_owned(),
10114                code: Some(if e2e_ok { 0 } else { 1 }),
10115                output_tail: String::new(),
10116                duration_ms: 0,
10117                resource_blocked: false,
10118            }],
10119            verify_retried: false,
10120            e2e_deferred: false,
10121            e2e_defer_reason: None,
10122            fix: None,
10123            blocking,
10124            answered,
10125            expected,
10126            clean,
10127            progressed,
10128            vote_split: false,
10129            reconsideration: Vec::new(),
10130            verdict: None,
10131        }
10132    }
10133
10134    #[test]
10135    fn review_conclusion_is_none_when_nothing_has_run() {
10136        assert_eq!(review_conclusion(&[], 3), None);
10137    }
10138
10139    #[test]
10140    fn review_conclusion_is_none_while_rounds_remain() {
10141        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
10142        assert_eq!(review_conclusion(&rounds, 3), None);
10143    }
10144
10145    #[test]
10146    fn review_conclusion_is_gating_once_a_round_is_clean() {
10147        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
10148        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
10149    }
10150
10151    #[test]
10152    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
10153        let rounds = vec![
10154            review_round(false, 1, 2, 2, true, true),
10155            review_round(false, 1, 2, 2, true, true),
10156        ];
10157        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
10158    }
10159
10160    #[test]
10161    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
10162        let rounds = vec![
10163            review_round(false, 1, 2, 2, true, true),
10164            review_round(false, 1, 2, 2, true, false),
10165        ];
10166        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
10167    }
10168
10169    #[test]
10170    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
10171        // Magi never got a command to run against this round's own head — a
10172        // resource-blocked attempt, not a red one — so this must never
10173        // settle on `Blocked` the way a genuine e2e failure would. `None`
10174        // here is what tells `Runner::review_loop` to retry the check
10175        // itself rather than trust this cheap recomputation with a verdict
10176        // it cannot actually produce.
10177        let mut blocked = review_round(false, 1, 2, 2, true, false);
10178        blocked.e2e[0].resource_blocked = true;
10179        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
10180        assert_eq!(review_conclusion(&rounds, 2), None);
10181    }
10182
10183    #[test]
10184    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
10185        // Missing input, not a verified tree — never a hand-off candidate.
10186        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
10187        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
10188    }
10189
10190    #[test]
10191    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
10192        let rounds = vec![
10193            review_round(false, 1, 2, 2, false, true),
10194            review_round(false, 1, 2, 2, false, true),
10195        ];
10196        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
10197    }
10198
10199    fn secs(n: u64) -> Duration {
10200        Duration::from_secs(n)
10201    }
10202
10203    /// A throwaway repo with one commit on `main`, for tests that need `merge`
10204    /// to make real (and, if it runs at all, real*ly fail*) git calls.
10205    fn init_repo(dir: &Path) {
10206        let run = |args: &[&str]| {
10207            let out = std::process::Command::new("git")
10208                .args(args)
10209                .current_dir(dir)
10210                .quiet()
10211                .output()
10212                .expect("spawn git");
10213            assert!(
10214                out.status.success(),
10215                "git {args:?} failed: {}",
10216                String::from_utf8_lossy(&out.stderr)
10217            );
10218        };
10219        run(&["init", "-b", "main"]);
10220        run(&["config", "user.name", "magi test"]);
10221        run(&["config", "user.email", "magi@example.com"]);
10222        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
10223        run(&["add", "-A"]);
10224        run(&["commit", "-m", "init"]);
10225    }
10226
10227    // `settle_questions` is what closes the ghost the phone showed: a run's
10228    // seat asked something, the run then ended, and nothing was left to
10229    // abandon the question it left `open`. `HOME` is a process-wide
10230    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
10231    // first time it runs in the binary — every test below still reaches the
10232    // same directory whichever call won, and each gets its own run id from
10233    // `RunState::new`, so they never collide there.
10234    fn ask_test_home() {
10235        crate::run::pin_test_home();
10236    }
10237
10238    /// A minimal, git-free `Runner` at a given status — `settle_questions`
10239    /// reads nothing else off it.
10240    fn runner_at(status: RunStatus) -> Runner {
10241        let mut state = RunState::new(
10242            PathBuf::from("/nonexistent/repo"),
10243            "main".to_owned(),
10244            "deadbeef".to_owned(),
10245            "task".to_owned(),
10246            Config::default(),
10247        );
10248        state.status = status;
10249        Runner {
10250            state,
10251            roles: ResolvedRoles {
10252                implementers: Vec::new(),
10253                judges: Vec::new(),
10254                reviewers: Vec::new(),
10255                fixer: None,
10256                conductor: conductor(),
10257                implementer_roster: Vec::new(),
10258                judge_roster: Vec::new(),
10259                reviewer_roster: Vec::new(),
10260            },
10261            sem: Arc::new(Semaphore::new(1)),
10262            pause: Pause::new(),
10263            interrupt: Pause::new(),
10264        }
10265    }
10266
10267    /// `park_here` folding in the reason `Pause::park_because` recorded -
10268    /// this is what lets an operator reading a run's events tell an
10269    /// interrupt-driven park from an ordinary shutdown park.
10270    #[test]
10271    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
10272        crate::run::pin_test_home();
10273        let mut runner = runner_at(RunStatus::Implementing);
10274        let interrupt = Pause::new();
10275        runner.watch_interrupt(interrupt.clone());
10276
10277        interrupt.park_because("task a1b2 asked to run first");
10278
10279        assert!(runner.park_here().expect("park_here"));
10280        assert!(runner.state.parked);
10281        let last = runner.state.events.last().expect("a park event");
10282        assert_eq!(last.node, "park");
10283        assert!(
10284            last.message.contains("task a1b2 asked to run first"),
10285            "expected the interrupt reason in {:?}",
10286            last.message
10287        );
10288    }
10289
10290    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
10291    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
10292    /// cleared) must not make a *different* run - one only watching its own,
10293    /// unshared interrupt `Pause` - see itself as parked. If a future change
10294    /// ever collapsed these back into one handle, the interrupt scheduler
10295    /// would park every run for the rest of the daemon's life, not just the
10296    /// one it meant to interrupt.
10297    #[test]
10298    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
10299        crate::run::pin_test_home();
10300        let mut runner = runner_at(RunStatus::Implementing);
10301        let shutdown = Pause::new();
10302        runner.on_pause(shutdown.clone());
10303        let interrupt = Pause::new();
10304        runner.watch_interrupt(interrupt.clone());
10305
10306        // Nobody has asked for anything yet.
10307        assert!(!runner.park_here().expect("park_here"));
10308        assert!(!runner.state.parked);
10309
10310        // Only the interrupt handle fires; the shutdown handle stays clear.
10311        interrupt.park_because("test");
10312        assert!(!shutdown.parked());
10313        assert!(runner.park_here().expect("park_here"));
10314    }
10315
10316    /// The property every prior attempt at this feature failed to pin down:
10317    /// asking a run to park while one of its nodes has a real, in-flight
10318    /// async operation running (an agent call, in production) must not cut
10319    /// that operation short. `park_here` is only ever consulted *between*
10320    /// `execute`'s node calls - see its own doc - so nothing inside a node
10321    /// can observe a park request until the node itself returns. This proves
10322    /// that structurally, with real `tokio` concurrency and a channel
10323    /// handshake (never a sleep, which would only prove "usually", not
10324    /// "cannot"): the "node" below reports that it has genuinely started,
10325    /// and only then is the park requested; the node still has to be told to
10326    /// finish before `park_here` is ever called, exactly mirroring every
10327    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
10328    /// in `execute`.
10329    #[tokio::test]
10330    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
10331        crate::run::pin_test_home();
10332        let mut runner = runner_at(RunStatus::Implementing);
10333        let interrupt = Pause::new();
10334        runner.watch_interrupt(interrupt.clone());
10335
10336        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
10337        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
10338
10339        // Stands in for one node's in-flight agent call: it proves it has
10340        // genuinely started, then blocks - exactly as a spawned CLI process
10341        // does - until told to finish.
10342        let node = async move {
10343            started_tx.send(()).expect("send started");
10344            finish_rx.await.expect("recv finish");
10345            "node finished"
10346        };
10347
10348        let interrupter = async move {
10349            started_rx.await.expect("recv started");
10350            // The call is now genuinely in flight. Ask it to park.
10351            interrupt.park_because("higher-priority task waiting");
10352            // Nothing the node does can observe this yet - there is no
10353            // check inside it, by construction - so let the executor run
10354            // anything pending and then let the node finish on its own.
10355            tokio::task::yield_now().await;
10356            finish_tx.send(()).expect("send finish");
10357        };
10358
10359        let (node_result, ()) = tokio::join!(node, interrupter);
10360        assert_eq!(
10361            node_result, "node finished",
10362            "the in-flight call ran to completion"
10363        );
10364
10365        // Only now, at the boundary the real `execute` would check right
10366        // after this node, does the park take effect.
10367        assert!(runner.park_here().expect("park_here"));
10368        assert!(runner.state.parked);
10369    }
10370
10371    /// A run parked mid-competition carries every field it had accumulated
10372    /// through the exact same disk round-trip an ordinary resume uses -
10373    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
10374    /// Nothing about parking for an interrupt is a special case of that path;
10375    /// this is what proves it rather than assuming it.
10376    #[test]
10377    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
10378        crate::run::pin_test_home();
10379        let mut runner = runner_at(RunStatus::Judging);
10380        // `Runner::resume` re-resolves roles from the saved config, which
10381        // refuses an empty roster - give it the same minimal one `conductor`
10382        // itself uses.
10383        runner.state.config.agents = vec![conductor()];
10384        runner.state.candidates = vec![Candidate {
10385            index: 0,
10386            label: 'A',
10387            agent: "alpha".to_owned(),
10388            branch: "magi/x/A".to_owned(),
10389            worktree: PathBuf::from("/nonexistent/worktree"),
10390            summary: "did the thing".to_owned(),
10391            stat: "1 file changed".to_owned(),
10392            files: 1,
10393            commits: 1,
10394            empty: false,
10395            failed: None,
10396            verified_noop: None,
10397            duration_ms: 1234,
10398            folded: false,
10399        }];
10400        let run_id = runner.state.id.clone();
10401
10402        let interrupt = Pause::new();
10403        runner.watch_interrupt(interrupt.clone());
10404        interrupt.park_because("task c3d4 asked to run first");
10405        assert!(runner.park_here().expect("park_here"));
10406
10407        let resumed = Runner::resume(&run_id).expect("resume");
10408        assert_eq!(resumed.state.candidates.len(), 1);
10409        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
10410        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
10411        assert_eq!(resumed.state.status, runner.state.status);
10412        assert!(
10413            resumed.state.parked,
10414            "still parked until `execute` actually walks the graph again"
10415        );
10416        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
10417    }
10418
10419    /// A fresh open question on `run`, stored and handed back for assertions.
10420    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
10421        let mut q = ask::Question::new(
10422            run.to_owned(),
10423            "implement".to_owned(),
10424            "impl-A".to_owned(),
10425            "Which storage backend should the cache use?".to_owned(),
10426            String::new(),
10427            vec!["SQLite".to_owned(), "Redis".to_owned()],
10428        );
10429        store.put(&mut q).unwrap();
10430        q
10431    }
10432
10433    #[test]
10434    fn a_failed_runs_open_question_is_abandoned() {
10435        ask_test_home();
10436        let store = ask::Questions::open();
10437        let mut runner = runner_at(RunStatus::Failed);
10438        let run = runner.state.id.clone();
10439        let q = ask_open_question(&store, &run);
10440
10441        runner.settle_questions();
10442
10443        let back = store.get(&q.id).unwrap();
10444        assert!(
10445            !back.status.open(),
10446            "the seat that asked died with the run; nobody is left to read an answer"
10447        );
10448        assert!(
10449            back.detail.contains(&run) && back.detail.contains("failed"),
10450            "the reason names what the run became, not just that it is gone: {}",
10451            back.detail
10452        );
10453    }
10454
10455    #[test]
10456    fn a_merged_runs_open_question_is_abandoned_too() {
10457        ask_test_home();
10458        let store = ask::Questions::open();
10459        // A run that finishes cleanly still leaves nobody to read an answer -
10460        // this is not only a failure-path cleanup.
10461        for status in [RunStatus::Merged, RunStatus::Ready] {
10462            let mut runner = runner_at(status);
10463            let run = runner.state.id.clone();
10464            let q = ask_open_question(&store, &run);
10465
10466            runner.settle_questions();
10467
10468            let back = store.get(&q.id).unwrap();
10469            assert!(
10470                !back.status.open(),
10471                "{status:?} run's question must not outlive the run"
10472            );
10473        }
10474    }
10475
10476    #[test]
10477    fn a_still_resumable_runs_open_question_is_left_alone() {
10478        ask_test_home();
10479        let store = ask::Questions::open();
10480        // `Blocked` and `Stalled` can still be resumed — the candidates, the
10481        // review round and the seat sessions are all still on disk — so a
10482        // question asked mid-round may yet get a real answer from a real
10483        // resume. Sweeping it here would be exactly the failure mode this
10484        // whole feature exists to avoid on the other side.
10485        for status in [RunStatus::Blocked, RunStatus::Stalled] {
10486            let mut runner = runner_at(status);
10487            let run = runner.state.id.clone();
10488            let q = ask_open_question(&store, &run);
10489
10490            runner.settle_questions();
10491
10492            let back = store.get(&q.id).unwrap();
10493            assert!(
10494                back.status.open(),
10495                "{status:?} is still alive; the question must still be waiting"
10496            );
10497        }
10498    }
10499
10500    #[test]
10501    fn settle_questions_never_touches_an_already_answered_question() {
10502        ask_test_home();
10503        let store = ask::Questions::open();
10504        let mut runner = runner_at(RunStatus::Failed);
10505        let run = runner.state.id.clone();
10506        let mut q = ask_open_question(&store, &run);
10507        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10508            .unwrap();
10509        store.put(&mut q).unwrap();
10510
10511        // Called twice, the way a crash-recovered daemon reclaim and the
10512        // graph's own cleanup both can for the same run — `abandon_for_run`
10513        // only ever touches what is still open, so this must be inert both
10514        // times, not merely the second.
10515        runner.settle_questions();
10516        runner.settle_questions();
10517
10518        let back = store.get(&q.id).unwrap();
10519        assert_eq!(
10520            back.status,
10521            ask::QuestionStatus::Answered,
10522            "a real answer is a decision on record, never overwritten by a sweep"
10523        );
10524    }
10525
10526    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
10527    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
10528    /// without merging, whose winner is still the operator's answer to read.
10529    /// Nothing previously called `fold_run` itself with a real `tally`, so
10530    /// this is the first test to pin down the one distinction the whole
10531    /// automatic-fold feature depends on: the winner's worktree and branch
10532    /// must survive, everything else sharing the run's worktree bay - a
10533    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
10534    /// second sweep treats every non-winner directory under the bay alike -
10535    /// must not.
10536    #[tokio::test]
10537    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10538        crate::run::pin_test_home();
10539        let tmp = tempfile::tempdir().expect("tempdir");
10540        let repo = tmp.path().join("repo");
10541        std::fs::create_dir_all(&repo).unwrap();
10542        init_repo(&repo);
10543
10544        let mut config = Config::default();
10545        config.graph.worktree_root = Some(tmp.path().join("wt"));
10546
10547        let mut state = RunState::new(
10548            repo.clone(),
10549            "main".to_owned(),
10550            "deadbeef".to_owned(),
10551            "task".to_owned(),
10552            config,
10553        );
10554        let root = state.worktree_root();
10555        let wt_a = root.join("cand-A");
10556        let wt_b = root.join("cand-B");
10557        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10558            .await
10559            .expect("worktree A");
10560        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10561            .await
10562            .expect("worktree B");
10563
10564        state.candidates = vec![
10565            Candidate {
10566                index: 0,
10567                label: 'A',
10568                agent: "alpha".to_owned(),
10569                branch: "magi/x/A".to_owned(),
10570                worktree: wt_a.clone(),
10571                summary: String::new(),
10572                stat: String::new(),
10573                files: 0,
10574                commits: 0,
10575                empty: false,
10576                failed: None,
10577                verified_noop: None,
10578                duration_ms: 0,
10579                folded: false,
10580            },
10581            Candidate {
10582                index: 1,
10583                label: 'B',
10584                agent: "beta".to_owned(),
10585                branch: "magi/x/B".to_owned(),
10586                worktree: wt_b.clone(),
10587                summary: String::new(),
10588                stat: String::new(),
10589                files: 0,
10590                commits: 0,
10591                empty: false,
10592                failed: None,
10593                verified_noop: None,
10594                duration_ms: 0,
10595                folded: false,
10596            },
10597        ];
10598        state.tally = Some(Tally {
10599            first_choice: BTreeMap::from([('A', 1)]),
10600            borda: BTreeMap::new(),
10601            winner: 'A',
10602            rankings: 1,
10603            unanimous_initial: true,
10604            deliberated: false,
10605            changed_votes: 0,
10606            unanimous_final: true,
10607            tie_break: None,
10608            judges: 1,
10609            present: 1,
10610            quorum: 1,
10611            met_quorum: true,
10612            uncontested: None,
10613        });
10614        state.status = RunStatus::Ready;
10615
10616        fold_run(&mut state, false, &crate::run::home())
10617            .await
10618            .expect("fold_run");
10619
10620        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10621        assert!(
10622            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10623            "the unmerged winner's branch survives"
10624        );
10625        assert!(
10626            !state.candidates[0].folded,
10627            "the winner is not marked folded"
10628        );
10629
10630        assert!(!wt_b.exists(), "the loser's worktree is removed");
10631        assert!(
10632            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10633            "the loser's branch is removed"
10634        );
10635        assert!(state.candidates[1].folded, "the loser is marked folded");
10636    }
10637
10638    /// A branch handed to a later run is that run's (and its pull request's):
10639    /// folding the run that released it must not delete it.
10640    #[tokio::test]
10641    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10642        let tmp = tempfile::tempdir().expect("tempdir");
10643        let repo = tmp.path().join("repo");
10644        std::fs::create_dir_all(&repo).unwrap();
10645        init_repo(&repo);
10646        let home = tmp.path().join("home");
10647
10648        let mut config = Config::default();
10649        config.graph.worktree_root = Some(tmp.path().join("wt"));
10650        let mut state = RunState::new(
10651            repo.clone(),
10652            "main".to_owned(),
10653            "deadbeef".to_owned(),
10654            "task".to_owned(),
10655            config,
10656        );
10657        // The worktree is already gone (released); the branch survives.
10658        git::git(&repo, &["branch", "magi/x/A", "main"])
10659            .await
10660            .expect("branch");
10661        state.candidates = vec![Candidate {
10662            index: 0,
10663            label: 'A',
10664            agent: "alpha".to_owned(),
10665            branch: "magi/x/A".to_owned(),
10666            worktree: state.worktree_root().join("cand-A"),
10667            summary: String::new(),
10668            stat: String::new(),
10669            files: 0,
10670            commits: 0,
10671            empty: false,
10672            failed: None,
10673            verified_noop: None,
10674            duration_ms: 0,
10675            folded: true,
10676        }];
10677        state.released_to = Some("20260901-000000-new1".to_owned());
10678        state.released_branches = vec!["magi/x/A".to_owned()];
10679
10680        fold_run(&mut state, true, &home).await.expect("fold_run");
10681
10682        assert!(
10683            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10684            "the handed-over branch survives a fold"
10685        );
10686    }
10687
10688    /// A winner with nothing ahead of the base is caught before `gh` is ever
10689    /// asked for a pull request, and the message carries what the task's
10690    /// references resolved to.
10691    #[tokio::test]
10692    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10693        let tmp = tempfile::tempdir().expect("tempdir");
10694        let repo = tmp.path().join("repo");
10695        std::fs::create_dir_all(&repo).unwrap();
10696        init_repo(&repo);
10697        let run = |args: &[&str]| {
10698            let out = std::process::Command::new("git")
10699                .quiet()
10700                .args(args)
10701                .current_dir(&repo)
10702                .output()
10703                .expect("spawn git");
10704            assert!(out.status.success(), "git {args:?}");
10705        };
10706        run(&["branch", "magi/x/A"]);
10707        run(&["checkout", "-q", "-b", "magi/x/B"]);
10708        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10709        run(&["add", "-A"]);
10710        run(&["commit", "-q", "-m", "work"]);
10711        run(&["checkout", "-q", "main"]);
10712
10713        // A pull request is compared against the remote's base, so the
10714        // fixture needs one. Before it exists nothing can be read, and the
10715        // answer must be "not empty".
10716        let probe = RunState::new(
10717            repo.clone(),
10718            "main".to_owned(),
10719            "deadbeef".to_owned(),
10720            "task".to_owned(),
10721            Config::default(),
10722        );
10723        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10724        let bare = tmp.path().join("origin.git");
10725        let out = std::process::Command::new("git")
10726            .quiet()
10727            .args(["init", "-q", "--bare"])
10728            .arg(&bare)
10729            .output()
10730            .expect("spawn git");
10731        assert!(out.status.success(), "git init --bare");
10732        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10733        run(&["push", "-q", "origin", "main"]);
10734
10735        let mut state = RunState::new(
10736            repo.clone(),
10737            "main".to_owned(),
10738            "deadbeef".to_owned(),
10739            "task".to_owned(),
10740            Config::default(),
10741        );
10742        state.seeds = vec![refs::Seed {
10743            token: "magi/27b2/A".to_owned(),
10744            kind: refs::SeedKind::Unresolved,
10745            sha: String::new(),
10746            branch: true,
10747            detail: "no branch or commit named magi/27b2/A".to_owned(),
10748        }];
10749
10750        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10751        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10752        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10753        let detail = empty_candidate_detail(&state, "main");
10754        assert!(detail.starts_with("empty candidate"), "{detail}");
10755        assert!(detail.contains("magi/27b2/A"), "{detail}");
10756    }
10757
10758    /// `status == Ready` used to be read as "this is the harmless
10759    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
10760    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
10761    /// run's PR was closed without merging — and reentering `merge` with
10762    /// `mode` still `Pr` does not know the difference, so it pushed and
10763    /// opened a second pull request. `mode == Local` reproduces the same
10764    /// blind spot without a network call: reentry must not attempt another
10765    /// git merge once this node has already recorded an outcome.
10766    #[tokio::test]
10767    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10768        let tmp = tempfile::tempdir().expect("tempdir");
10769        let repo = tmp.path().join("repo");
10770        std::fs::create_dir_all(&repo).unwrap();
10771        init_repo(&repo);
10772
10773        let mut config = Config::default();
10774        config.merge.mode = MergeMode::Local;
10775
10776        let mut state = RunState::new(
10777            repo.clone(),
10778            "main".to_owned(),
10779            "deadbeef".to_owned(),
10780            "task".to_owned(),
10781            config,
10782        );
10783        state.candidates = vec![Candidate {
10784            index: 0,
10785            label: 'A',
10786            agent: "alpha".to_owned(),
10787            branch: "does-not-exist".to_owned(),
10788            worktree: repo.clone(),
10789            summary: String::new(),
10790            stat: String::new(),
10791            files: 0,
10792            commits: 0,
10793            empty: false,
10794            failed: None,
10795            verified_noop: None,
10796            duration_ms: 0,
10797            folded: false,
10798        }];
10799        state.tally = Some(Tally {
10800            first_choice: BTreeMap::from([('A', 1)]),
10801            borda: BTreeMap::new(),
10802            winner: 'A',
10803            rankings: 1,
10804            unanimous_initial: true,
10805            deliberated: false,
10806            changed_votes: 0,
10807            unanimous_final: true,
10808            tie_break: None,
10809            judges: 0,
10810            present: 0,
10811            quorum: 0,
10812            met_quorum: true,
10813            uncontested: Some("only candidate A produced a change".to_owned()),
10814        });
10815        state.reviews = vec![ReviewRound {
10816            round: 1,
10817            head: "deadbeef".to_owned(),
10818            verified_head: None,
10819            verified_at: None,
10820            reviews: Vec::new(),
10821            e2e: Vec::new(),
10822            fix: None,
10823            blocking: 0,
10824            answered: 0,
10825            expected: 0,
10826            clean: true,
10827            verify_retried: false,
10828            e2e_deferred: false,
10829            e2e_defer_reason: None,
10830            progressed: false,
10831            vote_split: false,
10832            reconsideration: Vec::new(),
10833            verdict: None,
10834        }];
10835        state.gate = vec![CommandOutcome {
10836            command: "test".to_owned(),
10837            code: Some(0),
10838            output_tail: String::new(),
10839            duration_ms: 0,
10840            resource_blocked: false,
10841        }];
10842        state.gate_ran = true;
10843        // Reached its conclusion already — e.g. `land` closing the PR without
10844        // merging it, which (like the honest `MergeMode::None` path) leaves
10845        // `status` at `Ready`. The recorded outcome is what actually marks
10846        // this node done.
10847        state.status = RunStatus::Ready;
10848        state.merge = Some(MergeOutcome {
10849            mode: MergeMode::Local,
10850            ok: false,
10851            detail: "already concluded".to_owned(),
10852            empty: false,
10853        });
10854
10855        let mut runner = Runner {
10856            state,
10857            roles: ResolvedRoles {
10858                implementers: Vec::new(),
10859                judges: Vec::new(),
10860                reviewers: Vec::new(),
10861                fixer: None,
10862                conductor: conductor(),
10863                implementer_roster: Vec::new(),
10864                judge_roster: Vec::new(),
10865                reviewer_roster: Vec::new(),
10866            },
10867            sem: Arc::new(Semaphore::new(1)),
10868            pause: Pause::new(),
10869            interrupt: Pause::new(),
10870        };
10871
10872        runner.merge().await.expect("merge");
10873
10874        assert_eq!(
10875            runner.state.status,
10876            RunStatus::Ready,
10877            "a concluded run's status must not change on reentry"
10878        );
10879        assert_eq!(
10880            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10881            Some("already concluded"),
10882            "merge must not run again once the node already recorded an outcome"
10883        );
10884    }
10885
10886    /// `gate` leaves `state.gate_ran` false both before it has ever run and
10887    /// when its last attempt was resource-blocked (the shared build cache
10888    /// could not be acquired or confirmed fresh in time - see
10889    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
10890    /// `Vec` this also leaves behind used to read as "nothing failed" and let
10891    /// a run merge a tree the gate never actually checked - exactly the case
10892    /// a contended cache produces on every retry until it clears. `merge`
10893    /// must refuse until `gate` has actually recorded an attempt.
10894    #[tokio::test]
10895    async fn merge_refuses_a_gate_that_has_not_actually_run() {
10896        let tmp = tempfile::tempdir().expect("tempdir");
10897        let repo = tmp.path().join("repo");
10898        std::fs::create_dir_all(&repo).unwrap();
10899        init_repo(&repo);
10900
10901        let mut config = Config::default();
10902        config.merge.mode = MergeMode::Local;
10903
10904        let mut state = RunState::new(
10905            repo.clone(),
10906            "main".to_owned(),
10907            "deadbeef".to_owned(),
10908            "task".to_owned(),
10909            config,
10910        );
10911        state.candidates = vec![Candidate {
10912            index: 0,
10913            label: 'A',
10914            agent: "alpha".to_owned(),
10915            branch: "does-not-exist".to_owned(),
10916            worktree: repo.clone(),
10917            summary: String::new(),
10918            stat: String::new(),
10919            files: 0,
10920            commits: 0,
10921            empty: false,
10922            failed: None,
10923            verified_noop: None,
10924            duration_ms: 0,
10925            folded: false,
10926        }];
10927        state.tally = Some(Tally {
10928            first_choice: BTreeMap::from([('A', 1)]),
10929            borda: BTreeMap::new(),
10930            winner: 'A',
10931            rankings: 1,
10932            unanimous_initial: true,
10933            deliberated: false,
10934            changed_votes: 0,
10935            unanimous_final: true,
10936            tie_break: None,
10937            judges: 0,
10938            present: 0,
10939            quorum: 0,
10940            met_quorum: true,
10941            uncontested: Some("only candidate A produced a change".to_owned()),
10942        });
10943        state.reviews = vec![ReviewRound {
10944            round: 1,
10945            head: "deadbeef".to_owned(),
10946            verified_head: None,
10947            verified_at: None,
10948            reviews: Vec::new(),
10949            e2e: Vec::new(),
10950            fix: None,
10951            blocking: 0,
10952            answered: 0,
10953            expected: 0,
10954            clean: true,
10955            verify_retried: false,
10956            e2e_deferred: false,
10957            e2e_defer_reason: None,
10958            progressed: false,
10959            vote_split: false,
10960            reconsideration: Vec::new(),
10961            verdict: None,
10962        }];
10963        // The point: `gate` has not recorded anything yet.
10964        state.gate = Vec::new();
10965        state.gate_ran = false;
10966        state.status = RunStatus::Gating;
10967
10968        let mut runner = Runner {
10969            state,
10970            roles: ResolvedRoles {
10971                implementers: Vec::new(),
10972                judges: Vec::new(),
10973                reviewers: Vec::new(),
10974                fixer: None,
10975                conductor: conductor(),
10976                implementer_roster: Vec::new(),
10977                judge_roster: Vec::new(),
10978                reviewer_roster: Vec::new(),
10979            },
10980            sem: Arc::new(Semaphore::new(1)),
10981            pause: Pause::new(),
10982            interrupt: Pause::new(),
10983        };
10984
10985        runner.merge().await.expect("merge");
10986
10987        assert!(
10988            runner.state.merge.is_none(),
10989            "an empty gate must never be read as a passing one: {:?}",
10990            runner.state.merge
10991        );
10992    }
10993
10994    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
10995    /// commands configured and `merge.mode` is `none` (a review-only run).
10996    /// `gate` must still record a real attempt — zero commands, vacuously
10997    /// passed — rather than leaving `state.gate` empty in a way `merge`
10998    /// cannot tell apart from "never ran"; otherwise the run reaches
10999    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
11000    #[tokio::test]
11001    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
11002        let tmp = tempfile::tempdir().expect("tempdir");
11003        let repo = tmp.path().join("repo");
11004        std::fs::create_dir_all(&repo).unwrap();
11005        init_repo(&repo);
11006
11007        // Default config: `verify.gate` empty, `merge.mode` is `none`.
11008        let config = Config::default();
11009
11010        let mut state = RunState::new(
11011            repo.clone(),
11012            "main".to_owned(),
11013            "deadbeef".to_owned(),
11014            "task".to_owned(),
11015            config,
11016        );
11017        state.candidates = vec![Candidate {
11018            index: 0,
11019            label: 'A',
11020            agent: "alpha".to_owned(),
11021            branch: "does-not-exist".to_owned(),
11022            worktree: repo.clone(),
11023            summary: String::new(),
11024            stat: String::new(),
11025            files: 0,
11026            commits: 0,
11027            empty: false,
11028            failed: None,
11029            verified_noop: None,
11030            duration_ms: 0,
11031            folded: false,
11032        }];
11033        state.tally = Some(Tally {
11034            first_choice: BTreeMap::from([('A', 1)]),
11035            borda: BTreeMap::new(),
11036            winner: 'A',
11037            rankings: 1,
11038            unanimous_initial: true,
11039            deliberated: false,
11040            changed_votes: 0,
11041            unanimous_final: true,
11042            tie_break: None,
11043            judges: 0,
11044            present: 0,
11045            quorum: 0,
11046            met_quorum: true,
11047            uncontested: Some("only candidate A produced a change".to_owned()),
11048        });
11049        state.reviews = vec![ReviewRound {
11050            round: 1,
11051            head: "deadbeef".to_owned(),
11052            verified_head: None,
11053            verified_at: None,
11054            reviews: Vec::new(),
11055            e2e: Vec::new(),
11056            fix: None,
11057            blocking: 0,
11058            answered: 0,
11059            expected: 0,
11060            clean: true,
11061            verify_retried: false,
11062            e2e_deferred: false,
11063            e2e_defer_reason: None,
11064            progressed: false,
11065            vote_split: false,
11066            reconsideration: Vec::new(),
11067            verdict: None,
11068        }];
11069
11070        let mut runner = Runner {
11071            state,
11072            roles: ResolvedRoles {
11073                implementers: Vec::new(),
11074                judges: Vec::new(),
11075                reviewers: Vec::new(),
11076                fixer: None,
11077                conductor: conductor(),
11078                implementer_roster: Vec::new(),
11079                judge_roster: Vec::new(),
11080                reviewer_roster: Vec::new(),
11081            },
11082            sem: Arc::new(Semaphore::new(1)),
11083            pause: Pause::new(),
11084            interrupt: Pause::new(),
11085        };
11086
11087        runner.gate().await.expect("gate");
11088        assert!(
11089            runner.state.gate_ran,
11090            "zero configured commands is still a real attempt, not an unrun gate"
11091        );
11092        assert!(runner.state.gate.is_empty());
11093        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
11094        assert_ne!(
11095            runner.state.status,
11096            RunStatus::Blocked,
11097            "a gate with nothing to check must not read as failed"
11098        );
11099
11100        runner.merge().await.expect("merge");
11101        assert_eq!(
11102            runner.state.status,
11103            RunStatus::Ready,
11104            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
11105        );
11106    }
11107
11108    /// `Config::cache_dir` is derived from `verify.e2e` as well as
11109    /// `verify.gate` (so the e2e leg and the final gate never build against
11110    /// different directories). With zero `verify.gate` commands but a
11111    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
11112    /// that lease before discovering it had nothing to run - so a repo with
11113    /// no gate commands could come back `resource_blocked` (and therefore
11114    /// still `gate_ran == false`) on nothing but an unrelated run holding the
11115    /// cache, exactly the contention this run's own zero commands could
11116    /// never have touched. `gate` must recognise there is nothing to check
11117    /// before it ever asks for the lease.
11118    #[tokio::test]
11119    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
11120        crate::run::pin_test_home();
11121        let home = crate::run::home();
11122
11123        let tmp = tempfile::tempdir().expect("tempdir");
11124        let repo = tmp.path().join("repo");
11125        std::fs::create_dir_all(&repo).unwrap();
11126        init_repo(&repo);
11127        // Unique to this test, so holding its lease cannot collide with
11128        // another test sharing the same process-wide `home`.
11129        let cache_dir = tmp.path().join("target");
11130
11131        let mut config = Config::default();
11132        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
11133        // `verify.gate` stays empty (the default). Bounded so a regression
11134        // that does start waiting fails the test in seconds, not hangs it.
11135        config.graph.timeout_verify = Some(2);
11136
11137        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11138        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11139            .expect("no io error acquiring directly")
11140        {
11141            crate::cache::AcquireOutcome::Acquired(g) => g,
11142            crate::cache::AcquireOutcome::Busy(b) => {
11143                panic!("expected the direct acquire to win the lease first: {b:?}")
11144            }
11145        };
11146
11147        let mut state = RunState::new(
11148            repo.clone(),
11149            "main".to_owned(),
11150            "deadbeef".to_owned(),
11151            "task".to_owned(),
11152            config,
11153        );
11154        state.candidates = vec![Candidate {
11155            index: 0,
11156            label: 'A',
11157            agent: "alpha".to_owned(),
11158            branch: "does-not-exist".to_owned(),
11159            worktree: repo.clone(),
11160            summary: String::new(),
11161            stat: String::new(),
11162            files: 0,
11163            commits: 0,
11164            empty: false,
11165            failed: None,
11166            verified_noop: None,
11167            duration_ms: 0,
11168            folded: false,
11169        }];
11170        state.tally = Some(Tally {
11171            first_choice: BTreeMap::from([('A', 1)]),
11172            borda: BTreeMap::new(),
11173            winner: 'A',
11174            rankings: 1,
11175            unanimous_initial: true,
11176            deliberated: false,
11177            changed_votes: 0,
11178            unanimous_final: true,
11179            tie_break: None,
11180            judges: 0,
11181            present: 0,
11182            quorum: 0,
11183            met_quorum: true,
11184            uncontested: Some("only candidate A produced a change".to_owned()),
11185        });
11186        state.reviews = vec![ReviewRound {
11187            round: 1,
11188            head: "deadbeef".to_owned(),
11189            verified_head: None,
11190            verified_at: None,
11191            reviews: Vec::new(),
11192            e2e: Vec::new(),
11193            fix: None,
11194            blocking: 0,
11195            answered: 0,
11196            expected: 0,
11197            clean: true,
11198            verify_retried: false,
11199            e2e_deferred: false,
11200            e2e_defer_reason: None,
11201            progressed: false,
11202            vote_split: false,
11203            reconsideration: Vec::new(),
11204            verdict: None,
11205        }];
11206
11207        let mut runner = Runner {
11208            state,
11209            roles: ResolvedRoles {
11210                implementers: Vec::new(),
11211                judges: Vec::new(),
11212                reviewers: Vec::new(),
11213                fixer: None,
11214                conductor: conductor(),
11215                implementer_roster: Vec::new(),
11216                judge_roster: Vec::new(),
11217                reviewer_roster: Vec::new(),
11218            },
11219            sem: Arc::new(Semaphore::new(1)),
11220            pause: Pause::new(),
11221            interrupt: Pause::new(),
11222        };
11223
11224        let started = std::time::Instant::now();
11225        runner.gate().await.expect("gate");
11226        assert!(
11227            started.elapsed() < Duration::from_secs(1),
11228            "a gate with nothing to run must never wait on a lease it never needed"
11229        );
11230        assert!(
11231            runner.state.gate_ran,
11232            "zero commands is still a real, immediate attempt"
11233        );
11234        assert!(runner.state.gate.is_empty());
11235        assert_ne!(
11236            runner.state.status,
11237            RunStatus::Blocked,
11238            "must not read as resource-blocked on a lease it never asked for"
11239        );
11240    }
11241
11242    /// The addendum's second gap: a `verify.gate` command running for real
11243    /// wall-clock time had nothing at all to show for it in `active` before
11244    /// `run_commands` learned to record it — a run could sit in `Gating` for
11245    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
11246    /// what was actually happening. Proven with a genuinely still-running
11247    /// command, not just a before/after check on the final state: a poller
11248    /// task reads the same `run.json` `gate()` is writing, the same way the
11249    /// phone or `magi show` would, while the shell command is still blocked
11250    /// on its own release marker.
11251    #[tokio::test]
11252    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
11253        crate::run::pin_test_home();
11254
11255        let tmp = tempfile::tempdir().expect("tempdir");
11256        let repo = tmp.path().join("repo");
11257        std::fs::create_dir_all(&repo).unwrap();
11258        init_repo(&repo);
11259
11260        let mut config = Config::default();
11261        config.verify.gate = vec![
11262            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
11263             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
11264                .to_owned(),
11265        ];
11266
11267        let mut state = RunState::new(
11268            repo.clone(),
11269            "main".to_owned(),
11270            "deadbeef".to_owned(),
11271            "task".to_owned(),
11272            config,
11273        );
11274        let run_id = state.id.clone();
11275        state.candidates = vec![Candidate {
11276            index: 0,
11277            label: 'A',
11278            agent: "alpha".to_owned(),
11279            branch: "does-not-exist".to_owned(),
11280            worktree: repo.clone(),
11281            summary: String::new(),
11282            stat: String::new(),
11283            files: 0,
11284            commits: 0,
11285            empty: false,
11286            failed: None,
11287            verified_noop: None,
11288            duration_ms: 0,
11289            folded: false,
11290        }];
11291        state.tally = Some(Tally {
11292            first_choice: BTreeMap::from([('A', 1)]),
11293            borda: BTreeMap::new(),
11294            winner: 'A',
11295            rankings: 1,
11296            unanimous_initial: true,
11297            deliberated: false,
11298            changed_votes: 0,
11299            unanimous_final: true,
11300            tie_break: None,
11301            judges: 0,
11302            present: 0,
11303            quorum: 0,
11304            met_quorum: true,
11305            uncontested: Some("only candidate A produced a change".to_owned()),
11306        });
11307        state.reviews = vec![ReviewRound {
11308            round: 1,
11309            head: "deadbeef".to_owned(),
11310            verified_head: None,
11311            verified_at: None,
11312            reviews: Vec::new(),
11313            e2e: Vec::new(),
11314            fix: None,
11315            blocking: 0,
11316            answered: 0,
11317            expected: 0,
11318            clean: true,
11319            verify_retried: false,
11320            e2e_deferred: false,
11321            e2e_defer_reason: None,
11322            progressed: false,
11323            vote_split: false,
11324            reconsideration: Vec::new(),
11325            verdict: None,
11326        }];
11327
11328        let mut runner = Runner {
11329            state,
11330            roles: ResolvedRoles {
11331                implementers: Vec::new(),
11332                judges: Vec::new(),
11333                reviewers: Vec::new(),
11334                fixer: None,
11335                conductor: conductor(),
11336                implementer_roster: Vec::new(),
11337                judge_roster: Vec::new(),
11338                reviewer_roster: Vec::new(),
11339            },
11340            sem: Arc::new(Semaphore::new(1)),
11341            pause: Pause::new(),
11342            interrupt: Pause::new(),
11343        };
11344
11345        let started_marker = repo.join("started.marker");
11346        let release_marker = repo.join("release.marker");
11347        let poller = tokio::spawn(async move {
11348            // Bounded so a regression that never records the task entry
11349            // fails this test in seconds instead of hanging the suite —
11350            // the same shape `a_park_requested_while_a_seat_is_mid_call_
11351            // does_not_cut_it_short` uses for the same reason.
11352            for _ in 0..100 {
11353                if started_marker.exists()
11354                    && let Ok(s) = crate::run::RunState::load(&run_id)
11355                    && let Some(a) = s.active.get("gate")
11356                {
11357                    std::fs::write(&release_marker, b"go").expect("release marker");
11358                    return Some(a.clone());
11359                }
11360                tokio::time::sleep(Duration::from_millis(50)).await;
11361            }
11362            None
11363        });
11364
11365        runner.gate().await.expect("gate");
11366        let captured = poller.await.expect("poller task");
11367        let captured = captured.expect(
11368            "the poller never saw a `gate` task entry in run.json while the command was \
11369             still blocked on its own release marker",
11370        );
11371
11372        assert_eq!(captured.task.as_deref(), Some("gate"));
11373        assert_eq!(captured.node, "gate");
11374        assert_eq!(captured.index, Some(1));
11375        assert_eq!(captured.total, Some(1));
11376        assert!(
11377            captured
11378                .command
11379                .as_deref()
11380                .is_some_and(|c| c.contains("started.marker")),
11381            "{captured:?}"
11382        );
11383
11384        assert!(
11385            runner.state.active.is_empty(),
11386            "the entry must be cleared once the command actually finished: {:?}",
11387            runner.state.active
11388        );
11389        assert!(runner.state.gate_ran);
11390        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
11391    }
11392
11393    /// The hand-off over a blocking finding a reviewer rejected on leaves a
11394    /// record for `land`; one with only a Minor, or no reject, leaves none.
11395    #[tokio::test]
11396    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
11397        use crate::verdict::{Finding, ReviewVote, Severity};
11398        crate::run::pin_test_home();
11399        let tmp = tempfile::tempdir().expect("tempdir");
11400        let repo = tmp.path().join("repo");
11401        std::fs::create_dir_all(&repo).unwrap();
11402        init_repo(&repo);
11403
11404        for (severity, vote, expect) in [
11405            (Severity::Major, ReviewVote::Reject, true),
11406            (Severity::Minor, ReviewVote::Reject, false),
11407            (Severity::Major, ReviewVote::Approve, false),
11408        ] {
11409            let mut round = review_round(false, 1, 1, 1, false, true);
11410            round.reviews = vec![ReviewRecord {
11411                reviewer: 1,
11412                agent: "alpha".to_owned(),
11413                summary: String::new(),
11414                findings: vec![Finding {
11415                    id: "R1-1-1".to_owned(),
11416                    severity,
11417                    file: None,
11418                    line: None,
11419                    title: "t".to_owned(),
11420                    detail: String::new(),
11421                }],
11422                vote: Some(vote),
11423                failed: None,
11424                duration_ms: 0,
11425                attempts: 0,
11426            }];
11427            let mut state = RunState::new(
11428                repo.clone(),
11429                "main".to_owned(),
11430                "deadbeef".to_owned(),
11431                "task".to_owned(),
11432                Config::default(),
11433            );
11434            state.reviews = vec![round];
11435            let mut runner = Runner {
11436                state,
11437                roles: ResolvedRoles {
11438                    implementers: Vec::new(),
11439                    judges: Vec::new(),
11440                    reviewers: Vec::new(),
11441                    fixer: None,
11442                    conductor: conductor(),
11443                    implementer_roster: Vec::new(),
11444                    judge_roster: Vec::new(),
11445                    reviewer_roster: Vec::new(),
11446                },
11447                sem: Arc::new(Semaphore::new(1)),
11448                pause: Pause::new(),
11449                interrupt: Pause::new(),
11450            };
11451            let shell = runner.state.config.shell();
11452            runner
11453                .stop_reviewing("round budget spent", &shell, &repo)
11454                .await
11455                .expect("stop_reviewing");
11456            assert_eq!(runner.state.status, RunStatus::Gating);
11457            assert_eq!(
11458                runner.state.contested_handoff.is_some(),
11459                expect,
11460                "{severity:?} + {vote:?}"
11461            );
11462        }
11463    }
11464
11465    /// The shape the incident this whole fix responds to actually had: the
11466    /// round budget spent, the last round's own e2e blocked on the shared
11467    /// build cache (held here by a live pid — this test process — exactly
11468    /// `cache`'s own unit tests' pattern for "another owner, still alive"
11469    /// without forking a process). `stop_reviewing` must retry it — not
11470    /// silently leave the round looking untouched (the catch-up-only half of
11471    /// the bug), and not read the contention as a red `e2e` and block the
11472    /// run on it (the other half). Called directly, the same way
11473    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
11474    /// above exercises `gate`, so this never needs a real cargo build to
11475    /// reach: the lease is never released, so `with_cache_lease` never gets
11476    /// past acquiring it into anything that would need a real workspace.
11477    #[tokio::test]
11478    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
11479        crate::run::pin_test_home();
11480        let home = crate::run::home();
11481
11482        let tmp = tempfile::tempdir().expect("tempdir");
11483        let repo = tmp.path().join("repo");
11484        std::fs::create_dir_all(&repo).unwrap();
11485        init_repo(&repo);
11486        let head = crate::git::rev_parse(&repo, "HEAD")
11487            .await
11488            .expect("rev-parse");
11489        // Unique to this test, so holding its lease cannot collide with
11490        // another test sharing the same process-wide `home`.
11491        let cache_dir = tmp.path().join("target");
11492
11493        let mut config = Config::default();
11494        config.verify.e2e = vec![format!(
11495            "CARGO_TARGET_DIR='{}' test -f README.md",
11496            cache_dir.display()
11497        )];
11498        config.graph.review_rounds = 1;
11499        // Bounded so a regression that does start waiting fails the test in
11500        // seconds, not hangs it.
11501        config.graph.timeout_verify = Some(2);
11502
11503        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11504        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11505            .expect("no io error acquiring directly")
11506        {
11507            crate::cache::AcquireOutcome::Acquired(g) => g,
11508            crate::cache::AcquireOutcome::Busy(b) => {
11509                panic!("expected the direct acquire to win the lease first: {b:?}")
11510            }
11511        };
11512
11513        let mut state = RunState::new(
11514            repo.clone(),
11515            "main".to_owned(),
11516            head.clone(),
11517            "task".to_owned(),
11518            config,
11519        );
11520        state.candidates = vec![Candidate {
11521            index: 0,
11522            label: 'A',
11523            agent: "alpha".to_owned(),
11524            branch: "does-not-exist".to_owned(),
11525            worktree: repo.clone(),
11526            summary: String::new(),
11527            stat: String::new(),
11528            files: 0,
11529            commits: 0,
11530            empty: false,
11531            failed: None,
11532            verified_noop: None,
11533            duration_ms: 0,
11534            folded: false,
11535        }];
11536        state.tally = Some(Tally {
11537            first_choice: BTreeMap::from([('A', 1)]),
11538            borda: BTreeMap::new(),
11539            winner: 'A',
11540            rankings: 1,
11541            unanimous_initial: true,
11542            deliberated: false,
11543            changed_votes: 0,
11544            unanimous_final: true,
11545            tie_break: None,
11546            judges: 0,
11547            present: 0,
11548            quorum: 0,
11549            met_quorum: true,
11550            uncontested: Some("only candidate A produced a change".to_owned()),
11551        });
11552        // The round budget's last round, deferred: `needs_catchup_run`'s
11553        // other trigger. `stop_reviewing`'s retry machinery must treat this
11554        // exactly like a resource-blocked attempt once it actually runs.
11555        state.reviews = vec![ReviewRound {
11556            round: 1,
11557            head: head.clone(),
11558            verified_head: None,
11559            verified_at: None,
11560            reviews: Vec::new(),
11561            e2e: Vec::new(),
11562            fix: None,
11563            blocking: 1,
11564            answered: 1,
11565            expected: 1,
11566            clean: false,
11567            verify_retried: false,
11568            e2e_deferred: true,
11569            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11570            progressed: false,
11571            vote_split: false,
11572            reconsideration: Vec::new(),
11573            verdict: None,
11574        }];
11575
11576        let mut runner = Runner {
11577            state,
11578            roles: ResolvedRoles {
11579                implementers: Vec::new(),
11580                judges: Vec::new(),
11581                reviewers: Vec::new(),
11582                fixer: None,
11583                conductor: conductor(),
11584                implementer_roster: Vec::new(),
11585                judge_roster: Vec::new(),
11586                reviewer_roster: Vec::new(),
11587            },
11588            sem: Arc::new(Semaphore::new(1)),
11589            pause: Pause::new(),
11590            interrupt: Pause::new(),
11591        };
11592
11593        let shell = runner.state.config.shell();
11594        runner
11595            .stop_reviewing("round budget spent", &shell, &repo)
11596            .await
11597            .expect("stop_reviewing");
11598
11599        let last = runner.state.reviews.last().expect("round record");
11600        assert_eq!(
11601            last.e2e_status(),
11602            E2eStatus::ResourceBlocked,
11603            "the shared cache is still held; the attempt must read as blocked, not deferred or \
11604             failed: {last:?}"
11605        );
11606        assert_eq!(
11607            last.verified_head.as_deref(),
11608            Some(head.as_str()),
11609            "which commit this attempt targeted is known even though nothing finished checking \
11610             it"
11611        );
11612        let first_attempt_at = last
11613            .verified_at
11614            .expect("when this attempt ran is known too");
11615        assert_ne!(
11616            runner.state.status,
11617            RunStatus::Blocked,
11618            "contention is evidence about the machine, not the patch — it must not settle the \
11619             run as blocked: {:?}",
11620            runner.state.status
11621        );
11622        assert!(
11623            !runner
11624                .state
11625                .events
11626                .iter()
11627                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11628            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11629            runner.state.events
11630        );
11631
11632        // The cache is still held: a later reentry must retry the same
11633        // round's verification again — not leave it looking exactly as
11634        // untouched as the first blocked attempt, which is indistinguishable
11635        // from never having tried again at all.
11636        runner
11637            .stop_reviewing("round budget spent", &shell, &repo)
11638            .await
11639            .expect("stop_reviewing retry");
11640        assert_eq!(
11641            runner.state.reviews.len(),
11642            1,
11643            "no new round was started: {:?}",
11644            runner.state.reviews
11645        );
11646        let last = runner.state.reviews.last().expect("round record");
11647        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11648        assert!(
11649            last.verified_at.expect("still known") > first_attempt_at,
11650            "a second reentry must be a fresh attempt, not a stale copy of the first"
11651        );
11652        assert_ne!(runner.state.status, RunStatus::Blocked);
11653
11654        held.release();
11655    }
11656
11657    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
11658    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
11659    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
11660    /// own top-of-function fast path (`review_conclusion`) correctly reads
11661    /// this shape as `None` rather than guessing `Blocked`, and the loop's
11662    /// own `for` range is empty once the round budget is spent, so
11663    /// `review_loop` must retry the check itself rather than silently doing
11664    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
11665    /// above exercises directly, but through `review_loop`'s own entry point
11666    /// this time, proving the wiring between the two rather than just the
11667    /// retry logic in isolation.
11668    #[tokio::test]
11669    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11670        crate::run::pin_test_home();
11671        let home = crate::run::home();
11672
11673        let tmp = tempfile::tempdir().expect("tempdir");
11674        let repo = tmp.path().join("repo");
11675        std::fs::create_dir_all(&repo).unwrap();
11676        init_repo(&repo);
11677        let head = crate::git::rev_parse(&repo, "HEAD")
11678            .await
11679            .expect("rev-parse");
11680        let cache_dir = tmp.path().join("target");
11681
11682        let mut config = Config::default();
11683        config.verify.e2e = vec![format!(
11684            "CARGO_TARGET_DIR='{}' test -f README.md",
11685            cache_dir.display()
11686        )];
11687        config.graph.review_rounds = 1;
11688        config.graph.timeout_verify = Some(2);
11689
11690        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11691        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11692            .expect("no io error acquiring directly")
11693        {
11694            crate::cache::AcquireOutcome::Acquired(g) => g,
11695            crate::cache::AcquireOutcome::Busy(b) => {
11696                panic!("expected the direct acquire to win the lease first: {b:?}")
11697            }
11698        };
11699
11700        let mut state = RunState::new(
11701            repo.clone(),
11702            "main".to_owned(),
11703            head.clone(),
11704            "task".to_owned(),
11705            config,
11706        );
11707        state.candidates = vec![Candidate {
11708            index: 0,
11709            label: 'A',
11710            agent: "alpha".to_owned(),
11711            branch: "does-not-exist".to_owned(),
11712            worktree: repo.clone(),
11713            summary: String::new(),
11714            stat: String::new(),
11715            files: 0,
11716            commits: 0,
11717            empty: false,
11718            failed: None,
11719            verified_noop: None,
11720            duration_ms: 0,
11721            folded: false,
11722        }];
11723        state.tally = Some(Tally {
11724            first_choice: BTreeMap::from([('A', 1)]),
11725            borda: BTreeMap::new(),
11726            winner: 'A',
11727            rankings: 1,
11728            unanimous_initial: true,
11729            deliberated: false,
11730            changed_votes: 0,
11731            unanimous_final: true,
11732            tie_break: None,
11733            judges: 0,
11734            present: 0,
11735            quorum: 0,
11736            met_quorum: true,
11737            uncontested: Some("only candidate A produced a change".to_owned()),
11738        });
11739        // The exact shape a prior process's `stop_reviewing` would have left
11740        // on disk: the round budget's last round, a real attempt already
11741        // made and already resource-blocked.
11742        state.reviews = vec![ReviewRound {
11743            round: 1,
11744            head: head.clone(),
11745            verified_head: Some(head.clone()),
11746            verified_at: Some(jiff::Timestamp::now()),
11747            reviews: Vec::new(),
11748            e2e: vec![CommandOutcome {
11749                command: format!(
11750                    "CARGO_TARGET_DIR='{}' test -f README.md",
11751                    cache_dir.display()
11752                ),
11753                code: None,
11754                output_tail: "waiting for the shared build cache".to_owned(),
11755                duration_ms: 0,
11756                resource_blocked: true,
11757            }],
11758            fix: None,
11759            blocking: 1,
11760            answered: 1,
11761            expected: 1,
11762            clean: false,
11763            verify_retried: false,
11764            e2e_deferred: false,
11765            e2e_defer_reason: None,
11766            progressed: false,
11767            vote_split: false,
11768            reconsideration: Vec::new(),
11769            verdict: None,
11770        }];
11771
11772        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11773        let mut runner = Runner {
11774            state,
11775            roles: ResolvedRoles {
11776                implementers: Vec::new(),
11777                judges: Vec::new(),
11778                reviewers: Vec::new(),
11779                fixer: None,
11780                conductor: conductor(),
11781                implementer_roster: Vec::new(),
11782                judge_roster: Vec::new(),
11783                reviewer_roster: Vec::new(),
11784            },
11785            sem: Arc::new(Semaphore::new(1)),
11786            pause: Pause::new(),
11787            interrupt: Pause::new(),
11788        };
11789
11790        // The lease is still held throughout, so this reentry's own retry is
11791        // also contended — proving `review_loop` actually tried again (not
11792        // that it happened to succeed) is what the timestamp comparison
11793        // below is for.
11794        runner.review_loop().await.expect("review_loop");
11795
11796        assert_eq!(
11797            runner.state.reviews.len(),
11798            1,
11799            "no new round was started on top of the unresolved one: {:?}",
11800            runner.state.reviews
11801        );
11802        let last = &runner.state.reviews[0];
11803        assert_eq!(
11804            last.e2e_status(),
11805            E2eStatus::ResourceBlocked,
11806            "still contended: {last:?}"
11807        );
11808        assert!(
11809            last.verified_at.expect("still known") > first_attempt_at,
11810            "review_loop must have actually retried the check, not left it exactly as found"
11811        );
11812        assert_ne!(
11813            runner.state.status,
11814            RunStatus::Blocked,
11815            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11816            runner.state.status
11817        );
11818
11819        held.release();
11820    }
11821
11822    #[tokio::test]
11823    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11824        crate::run::pin_test_home();
11825        let tmp = tempfile::tempdir().expect("tempdir");
11826        let repo = tmp.path().join("repo");
11827        std::fs::create_dir_all(&repo).unwrap();
11828        init_repo(&repo);
11829
11830        let mut config = Config::default();
11831        config.merge.mode = MergeMode::Pr;
11832        config.graph.land = true;
11833        config.graph.land_approval = false;
11834
11835        let mut state = RunState::new(
11836            repo.clone(),
11837            "main".to_owned(),
11838            "deadbeef".to_owned(),
11839            "task".to_owned(),
11840            config,
11841        );
11842        state.candidates = vec![Candidate {
11843            index: 0,
11844            label: 'A',
11845            agent: "alpha".to_owned(),
11846            branch: "does-not-exist".to_owned(),
11847            worktree: repo.clone(),
11848            summary: String::new(),
11849            stat: String::new(),
11850            files: 0,
11851            commits: 0,
11852            empty: false,
11853            failed: None,
11854            verified_noop: None,
11855            duration_ms: 0,
11856            folded: false,
11857        }];
11858        state.tally = Some(Tally {
11859            first_choice: BTreeMap::from([('A', 1)]),
11860            borda: BTreeMap::new(),
11861            winner: 'A',
11862            rankings: 1,
11863            unanimous_initial: true,
11864            deliberated: false,
11865            changed_votes: 0,
11866            unanimous_final: true,
11867            tie_break: None,
11868            judges: 0,
11869            present: 0,
11870            quorum: 0,
11871            met_quorum: true,
11872            uncontested: Some("only candidate A produced a change".to_owned()),
11873        });
11874        state.reviews = vec![ReviewRound {
11875            round: 1,
11876            head: "deadbeef".to_owned(),
11877            verified_head: None,
11878            verified_at: None,
11879            reviews: Vec::new(),
11880            e2e: Vec::new(),
11881            fix: None,
11882            blocking: 0,
11883            answered: 0,
11884            expected: 0,
11885            clean: true,
11886            verify_retried: false,
11887            e2e_deferred: false,
11888            e2e_defer_reason: None,
11889            progressed: false,
11890            vote_split: false,
11891            reconsideration: Vec::new(),
11892            verdict: None,
11893        }];
11894        state.gate = vec![CommandOutcome {
11895            command: "test".to_owned(),
11896            code: Some(0),
11897            output_tail: String::new(),
11898            duration_ms: 0,
11899            resource_blocked: false,
11900        }];
11901        state.gate_ran = true;
11902        // A first pass through `merge` already pushed and opened this pull
11903        // request; `status` is `Landing` because a previous call into `land`
11904        // parked or was interrupted before it reached a terminal outcome.
11905        state.status = RunStatus::Landing;
11906        state.merge = Some(MergeOutcome {
11907            mode: MergeMode::Pr,
11908            ok: true,
11909            detail: "https://example.invalid/x/y/pull/1".to_owned(),
11910            empty: false,
11911        });
11912
11913        // The Landing-resume shortcut calls `run_land` directly rather than
11914        // through `merge`, which is exactly the call site that used to skip
11915        // `settle_questions` - see the fixture below.
11916        ask_test_home();
11917        // A real resume loads a persisted run; driver ownership is checked
11918        // against that record before the Landing shortcut is entered.
11919        state.save().expect("save landing run");
11920        let store = ask::Questions::open();
11921        let q = ask_open_question(&store, &state.id);
11922
11923        let mut runner = Runner {
11924            state,
11925            roles: ResolvedRoles {
11926                implementers: Vec::new(),
11927                judges: Vec::new(),
11928                reviewers: Vec::new(),
11929                fixer: None,
11930                conductor: conductor(),
11931                implementer_roster: Vec::new(),
11932                judge_roster: Vec::new(),
11933                reviewer_roster: Vec::new(),
11934            },
11935            sem: Arc::new(Semaphore::new(1)),
11936            pause: Pause::new(),
11937            interrupt: Pause::new(),
11938        };
11939
11940        // `execute`, not `merge` directly: the Landing-resume shortcut lives
11941        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
11942        // exactly because `review_loop` would otherwise clobber the marker
11943        // first.
11944        runner.execute().await.expect("execute");
11945
11946        assert_eq!(
11947            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11948            Some("https://example.invalid/x/y/pull/1"),
11949            "reentry must not push again or open a second pull request over the \
11950             one `land` is already watching"
11951        );
11952        assert_ne!(
11953            runner.state.status,
11954            RunStatus::Landing,
11955            "land could not actually reach the fake pull request, so it must \
11956             have given up rather than left the run silently parked forever"
11957        );
11958        // `land` could not reach the fake pull request, so it gave up into
11959        // `Blocked` - still resumable, so the question must not have been
11960        // swept just because this branch now also calls `settle_questions`.
11961        assert_eq!(runner.state.status, RunStatus::Blocked);
11962        assert!(
11963            store.get(&q.id).unwrap().status.open(),
11964            "Blocked is still alive; settle_questions must have been a no-op here"
11965        );
11966    }
11967
11968    fn state_with_round(round: ReviewRound) -> RunState {
11969        let mut s = RunState::new(
11970            PathBuf::from("/repo"),
11971            "main".to_owned(),
11972            "abc1234".to_owned(),
11973            "add retries".to_owned(),
11974            Config::default(),
11975        );
11976        s.reviews = vec![round];
11977        s
11978    }
11979
11980    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11981        crate::verdict::Finding {
11982            id: id.to_owned(),
11983            severity,
11984            file: None,
11985            line: None,
11986            title: title.to_owned(),
11987            detail: String::new(),
11988        }
11989    }
11990
11991    #[test]
11992    fn pr_body_names_open_findings_and_declined_ones() {
11993        let round = ReviewRound {
11994            round: 2,
11995            head: "deadbee".to_owned(),
11996            verified_head: None,
11997            verified_at: None,
11998            reviews: vec![ReviewRecord {
11999                attempts: 0,
12000                reviewer: 1,
12001                agent: "alpha".to_owned(),
12002                summary: String::new(),
12003                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
12004                vote: None,
12005                failed: None,
12006                duration_ms: 0,
12007            }],
12008            e2e: vec![CommandOutcome {
12009                command: "cargo test".to_owned(),
12010                code: Some(0),
12011                output_tail: String::new(),
12012                duration_ms: 0,
12013                resource_blocked: false,
12014            }],
12015            verify_retried: false,
12016            e2e_deferred: false,
12017            e2e_defer_reason: None,
12018            fix: Some(FixRecord {
12019                agent: "alpha".to_owned(),
12020                addressed: Vec::new(),
12021                rejected: vec![crate::verdict::Rejection {
12022                    id: "R1-1-1".to_owned(),
12023                    why: "not reachable from any caller".to_owned(),
12024                }],
12025                notes: String::new(),
12026                committed: true,
12027                failed: None,
12028                duration_ms: 0,
12029                continuation: None,
12030            }),
12031            blocking: 0,
12032            answered: 1,
12033            expected: 1,
12034            clean: false,
12035            progressed: true,
12036            vote_split: false,
12037            reconsideration: Vec::new(),
12038            verdict: None,
12039        };
12040        let state = state_with_round(round);
12041        let body = pr_message(&state, 'A').body;
12042
12043        assert!(body.contains("add retries"), "the task must still be there");
12044        assert!(body.contains("R2-1-1"), "{body}");
12045        assert!(body.contains("unused import"), "{body}");
12046        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
12047        assert!(
12048            body.contains("not reachable from any caller"),
12049            "the reason it was declined: {body}"
12050        );
12051    }
12052
12053    #[test]
12054    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
12055        let round = ReviewRound {
12056            round: 1,
12057            head: "deadbee".to_owned(),
12058            verified_head: None,
12059            verified_at: None,
12060            reviews: vec![ReviewRecord {
12061                attempts: 0,
12062                reviewer: 1,
12063                agent: "alpha".to_owned(),
12064                summary: String::new(),
12065                findings: Vec::new(),
12066                vote: None,
12067                failed: None,
12068                duration_ms: 0,
12069            }],
12070            e2e: Vec::new(),
12071            verify_retried: false,
12072            e2e_deferred: false,
12073            e2e_defer_reason: None,
12074            fix: None,
12075            blocking: 0,
12076            answered: 1,
12077            expected: 1,
12078            clean: true,
12079            progressed: false,
12080            vote_split: false,
12081            reconsideration: Vec::new(),
12082            verdict: None,
12083        };
12084        let state = state_with_round(round);
12085        let body = pr_message(&state, 'A').body;
12086        assert!(!body.contains("Open review findings"), "{body}");
12087        assert!(!body.contains("Declined"), "{body}");
12088    }
12089
12090    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
12091        let mut state = RunState::new(
12092            PathBuf::from("/repo"),
12093            "main".to_owned(),
12094            "abc1234".to_owned(),
12095            instruction.to_owned(),
12096            Config::default(),
12097        );
12098        state.candidates.push(Candidate {
12099            index: 0,
12100            label: 'A',
12101            agent: "alpha".to_owned(),
12102            branch: "magi/x/A".to_owned(),
12103            worktree: PathBuf::from("/wt"),
12104            summary: summary.to_owned(),
12105            stat: String::new(),
12106            files: 1,
12107            commits: 1,
12108            empty: false,
12109            failed: None,
12110            verified_noop: None,
12111            folded: false,
12112            duration_ms: 0,
12113        });
12114        state
12115    }
12116
12117    fn review_state(subjects: &[&str]) -> RunState {
12118        let mut state = state_with_summary(
12119            "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",
12120            "",
12121        );
12122        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
12123        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
12124        state
12125    }
12126
12127    /// A branch rebased onto a main that moved past the recorded
12128    /// `base_commit` is titled from its own first commit, never main's.
12129    #[tokio::test]
12130    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
12131        ask_test_home();
12132        let tmp = tempfile::tempdir().unwrap();
12133        let repo = tmp.path().join("repo");
12134        std::fs::create_dir_all(&repo).unwrap();
12135        init_repo(&repo);
12136        let origin = tmp.path().join("origin.git");
12137        let g = |dir: &Path, args: &[&str]| {
12138            let out = std::process::Command::new("git")
12139                .args(args)
12140                .current_dir(dir)
12141                .quiet()
12142                .output()
12143                .expect("spawn git");
12144            assert!(
12145                out.status.success(),
12146                "git {args:?}: {}",
12147                String::from_utf8_lossy(&out.stderr)
12148            );
12149        };
12150        g(
12151            tmp.path(),
12152            &[
12153                "clone",
12154                "--bare",
12155                "-q",
12156                repo.to_str().unwrap(),
12157                origin.to_str().unwrap(),
12158            ],
12159        );
12160        g(
12161            &repo,
12162            &["remote", "add", "origin", origin.to_str().unwrap()],
12163        );
12164        let c1 = git::rev_parse(&repo, "main").await.unwrap();
12165
12166        // Main moves on; the branch is built on top of the new main.
12167        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
12168        g(&repo, &["add", "-A"]);
12169        g(
12170            &repo,
12171            &["commit", "-q", "-m", "chore(deps): update a crate"],
12172        );
12173        g(&repo, &["push", "-q", "origin", "main"]);
12174        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
12175        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
12176        g(&repo, &["add", "-A"]);
12177        g(
12178            &repo,
12179            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
12180        );
12181        g(&repo, &["checkout", "-q", "main"]);
12182
12183        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
12184        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
12185        // Without a readable tracking ref the recorded base's merge base is used.
12186        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
12187        assert_eq!(fallback, c1);
12188
12189        let mut state = review_state(&[
12190            "chore(deps): update a crate",
12191            "fix(daemon): apply a chosen action",
12192        ]);
12193        state.repo = repo.clone();
12194        state.base_branch = "main".to_owned();
12195        state.base_commit = c1;
12196        refresh_reviewed_commits(&mut state, "feat/own").await;
12197        assert_eq!(
12198            state.reviewed_commits,
12199            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
12200        );
12201        assert_eq!(
12202            review_title(&state).as_deref(),
12203            Some("fix(daemon): apply a chosen action")
12204        );
12205        assert_eq!(
12206            leaked_subjects(&state, "feat/own").await,
12207            Some(vec!["chore(deps): update a crate".to_owned()])
12208        );
12209        // A stale tracking ref is still used when the fetch fails, but the
12210        // leak list is withheld.
12211        state.config.merge.remote = "nowhere".to_owned();
12212        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
12213    }
12214
12215    #[test]
12216    fn pr_message_review_single_commit_uses_its_subject() {
12217        let state = review_state(&["feat(nats): per-role user"]);
12218        let m = pr_message(&state, 'A');
12219        assert_eq!(m.title, "feat(nats): per-role user");
12220        assert!(!m.body.contains("Review the work already"), "{}", m.body);
12221        assert!(m.body.contains("## Commits under review"), "{}", m.body);
12222    }
12223
12224    #[test]
12225    fn pr_message_review_multi_commit_takes_the_oldest() {
12226        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
12227        let m = pr_message(&state, 'A');
12228        assert_eq!(m.title, "feat: the change");
12229        for s in ["feat: the change", "fix: typo", "fix: again"] {
12230            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
12231        }
12232    }
12233
12234    #[test]
12235    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
12236        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
12237            let mut state = review_state(&[first, "fix: later fixup"]);
12238            state.candidates[0].branch = "機能/ログイン".to_owned();
12239            let m = pr_message(&state, 'A');
12240            assert!(
12241                m.title.starts_with("chore: land candidate A of run"),
12242                "{}",
12243                m.title
12244            );
12245        }
12246    }
12247
12248    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
12249        BranchFacts {
12250            commits: commits
12251                .iter()
12252                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
12253                .collect(),
12254            stat: stat.to_owned(),
12255        }
12256    }
12257
12258    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
12259        let mut r = review_round(true, 0, 1, 1, true, true);
12260        r.round = round;
12261        r.fix = notes.map(|n| FixRecord {
12262            agent: "fixer".to_owned(),
12263            addressed: Vec::new(),
12264            rejected: Vec::new(),
12265            notes: n.to_owned(),
12266            committed: true,
12267            failed: None,
12268            duration_ms: 0,
12269            continuation: None,
12270        });
12271        r
12272    }
12273
12274    #[test]
12275    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
12276        let state = review_state(&["ignored"]);
12277        let f = facts(
12278            &[
12279                (
12280                    "fix(login): resolve PATH on macOS",
12281                    "Login shells skip rc files.",
12282                ),
12283                ("fix: address review", ""),
12284            ],
12285            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
12286        );
12287        let m = pr_message_with(&state, 'A', Some(&f));
12288        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
12289        assert!(
12290            m.body
12291                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
12292            "{}",
12293            m.body
12294        );
12295        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
12296        assert!(m.body.contains("## Diff stat"), "{}", m.body);
12297        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
12298        for banned in [
12299            "Review the work already",
12300            "no task statement",
12301            "Original task",
12302        ] {
12303            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
12304        }
12305        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
12306    }
12307
12308    #[test]
12309    fn pr_message_review_truncates_a_huge_first_commit_body() {
12310        let state = review_state(&["ignored"]);
12311        let f = facts(
12312            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
12313            "s",
12314        );
12315        let m = pr_message_with(&state, 'A', Some(&f));
12316        assert!(m.body.len() < 30_000, "{}", m.body.len());
12317        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
12318        assert!(
12319            m.body.contains("- ... 1 more commit(s)"),
12320            "{}",
12321            m.body.len()
12322        );
12323        assert!(m.body.ends_with("magi:candidate-a\n"));
12324    }
12325
12326    #[test]
12327    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
12328        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
12329        assert_eq!(m.title, "feat: x");
12330        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
12331        assert!(!m.body.contains("Diff stat"), "{}", m.body);
12332        assert!(!m.body.contains("no task statement"), "{}", m.body);
12333    }
12334
12335    #[test]
12336    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
12337        let mut state = review_state(&["日本語の件名"]);
12338        state.candidates[0].branch = "fix/macos-login-path".to_owned();
12339        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
12340        state.candidates[0].branch = "機能/ログイン".to_owned();
12341        assert!(
12342            pr_message(&state, 'A')
12343                .title
12344                .starts_with("chore: land candidate A")
12345        );
12346    }
12347
12348    #[test]
12349    fn pr_message_review_fixes_survive_a_clean_final_round() {
12350        let mut state = review_state(&["feat: x"]);
12351        state.reviews = vec![
12352            round_with_notes(1, Some("handled the PATH case")),
12353            round_with_notes(2, None),
12354        ];
12355        let body = pr_message(&state, 'A').body;
12356        assert!(
12357            body.contains("## Review fixes\n\nhandled the PATH case\n"),
12358            "{body}"
12359        );
12360        assert!(!body.contains("### Round"), "{body}");
12361
12362        state.reviews = vec![
12363            round_with_notes(1, Some("first fix")),
12364            round_with_notes(2, Some("")),
12365            round_with_notes(3, Some("second fix")),
12366            round_with_notes(4, None),
12367        ];
12368        let body = pr_message(&state, 'A').body;
12369        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
12370        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
12371        assert!(!body.contains("### Round 2"), "{body}");
12372    }
12373
12374    #[test]
12375    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
12376        let state = state_with_summary(
12377            "add retries to the client",
12378            "TITLE: feat: retries\n\nDid it.",
12379        );
12380        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
12381        assert_eq!(m.title, "feat: retries");
12382        assert!(
12383            m.body.contains("<summary>Original task</summary>"),
12384            "{}",
12385            m.body
12386        );
12387        assert!(!m.body.contains("Commits under review"), "{}", m.body);
12388        assert!(
12389            m.body
12390                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
12391            "{}",
12392            m.body
12393        );
12394    }
12395
12396    #[test]
12397    fn pr_message_review_bounds_a_long_english_subject() {
12398        let long = format!("feat: {}", "word ".repeat(100));
12399        let m = pr_message(&review_state(&[&long]), 'A');
12400        assert!(m.title.starts_with("feat: word"), "{}", m.title);
12401        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12402    }
12403
12404    #[test]
12405    fn pr_message_implementation_run_is_unchanged_by_review_support() {
12406        let state = state_with_summary("add retries\n\ndetails", "- did some things");
12407        let m = pr_message(&state, 'A');
12408        assert_eq!(m.title, "add retries");
12409        assert!(m.body.contains("<summary>Original task</summary>"));
12410        assert!(!m.body.contains("Commits under review"));
12411        assert_eq!(landing_subject_source(&state), state.instruction);
12412    }
12413
12414    #[test]
12415    fn review_run_squash_subject_is_the_change_not_the_prompt() {
12416        let state = review_state(&["feat: the change", "fix: typo"]);
12417        let source = landing_subject_source(&state);
12418        assert_eq!(land::merge_subject("", &source), "feat: the change");
12419        assert_eq!(
12420            land::merge_subject("magi: candidate A (uncommitted work)", &source),
12421            "feat: the change"
12422        );
12423        // An operator's rename still wins.
12424        assert_eq!(
12425            land::merge_subject("feat: renamed by hand", &source),
12426            "feat: renamed by hand"
12427        );
12428        let blank = review_state(&["日本語"]);
12429        assert!(
12430            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
12431        );
12432    }
12433
12434    #[test]
12435    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
12436        let state = review_state(&["feat: the change"]);
12437        let source = landing_subject_source(&state);
12438        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
12439        assert_eq!(
12440            land::merge_subject(landing_title(&state, old), &source),
12441            "feat: the change"
12442        );
12443        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
12444        let task = state_with_summary("add retries", "");
12445        assert_eq!(landing_title(&task, old), old);
12446    }
12447
12448    #[test]
12449    fn pr_message_describes_the_change_not_the_task() {
12450        let state = state_with_summary(
12451            "今回やってほしいこと: results projector を直す",
12452            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
12453        );
12454        let m = pr_message(&state, 'A');
12455        assert_eq!(m.title, "fix(web): batch the runs list reads");
12456        assert!(
12457            m.body.starts_with("## Summary\n\n- reads run.json once"),
12458            "{}",
12459            m.body
12460        );
12461        assert!(!m.body.contains("TITLE:"), "{}", m.body);
12462        let task_at = m.body.find("今回やってほしいこと").unwrap();
12463        let details_at = m.body.find("<details>").unwrap();
12464        assert!(
12465            details_at < task_at,
12466            "the task lives inside <details>: {}",
12467            m.body
12468        );
12469        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
12470        assert!(m.body.contains("magi:candidate-a"));
12471    }
12472
12473    #[test]
12474    fn pr_message_falls_back_to_the_task_without_a_title_line() {
12475        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
12476        let m = pr_message(&state, 'A');
12477        assert_eq!(m.title, "add retries");
12478        assert!(
12479            m.body.contains("## Summary\n\n- did some things"),
12480            "{}",
12481            m.body
12482        );
12483
12484        let none = RunState::new(
12485            PathBuf::from("/repo"),
12486            "main".to_owned(),
12487            "abc1234".to_owned(),
12488            "add retries".to_owned(),
12489            Config::default(),
12490        );
12491        let m = pr_message(&none, 'A');
12492        assert_eq!(m.title, "add retries");
12493        assert!(!m.body.contains("## Summary"), "{}", m.body);
12494    }
12495
12496    #[test]
12497    fn pr_message_refuses_the_candidate_commit_subject() {
12498        for bad in [
12499            "TITLE: magi: candidate A (uncommitted work)",
12500            "TITLE: chore: stuff (uncommitted work)",
12501            "TITLE:   ",
12502        ] {
12503            let state = state_with_summary("add retries", bad);
12504            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12505        }
12506    }
12507
12508    #[test]
12509    fn pr_message_bounds_a_very_long_task_and_title() {
12510        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12511        let state = state_with_summary(&long, "- nothing");
12512        let m = pr_message(&state, 'A');
12513        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12514        assert!(!m.title.contains('\n'));
12515
12516        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12517        let m = pr_message(&state, 'A');
12518        assert!(m.title.starts_with("feat: "));
12519        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12520        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12521    }
12522
12523    fn long_title_of(instruction: &str) -> String {
12524        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12525    }
12526
12527    #[test]
12528    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12529        let first = "Make the landing path keep a readable title for long tasks";
12530        let line = format!(
12531            "{first}. {}",
12532            "And then keep going with more words ".repeat(20)
12533        );
12534        let t = long_title_of(&line);
12535        assert_eq!(t, first);
12536        assert!(!t.starts_with("chore: land"));
12537    }
12538
12539    #[test]
12540    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12541        let line = "word ".repeat(200);
12542        let t = long_title_of(&line);
12543        assert!(t.ends_with("word..."), "{t}");
12544        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12545    }
12546
12547    #[test]
12548    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12549        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12550            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12551        }
12552    }
12553
12554    #[test]
12555    fn pr_message_title_limit_is_exact() {
12556        let at = "a".repeat(PR_TITLE_MAX);
12557        assert_eq!(long_title_of(&at), at);
12558        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12559        assert!(over.ends_with("..."), "{over}");
12560        assert_eq!(over.chars().count(), PR_TITLE_MAX);
12561    }
12562
12563    #[test]
12564    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12565        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12566        let t = long_title_of(&line);
12567        assert!(t.ends_with("..."), "{t}");
12568        assert!(t.is_ascii(), "{t}");
12569    }
12570
12571    #[test]
12572    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12573        let line = format!(
12574            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12575            "plus more filler words ".repeat(20)
12576        );
12577        let t = long_title_of(&line);
12578        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12579    }
12580
12581    #[test]
12582    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12583        let line = format!("\n\n# {}", "title words ".repeat(40));
12584        let state = state_with_summary(&line, "- nothing");
12585        let m = pr_message(&state, 'A');
12586        assert!(m.title.starts_with("title words"), "{}", m.title);
12587        assert_eq!(
12588            land::merge_subject(&m.title, &landing_subject_source(&state)),
12589            m.title
12590        );
12591        // An operator's rename wins untouched.
12592        assert_eq!(
12593            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12594            "feat: renamed by hand"
12595        );
12596    }
12597
12598    #[tokio::test]
12599    async fn github_text_rewrite_is_bounded_and_falls_back() {
12600        crate::run::pin_test_home();
12601        for (reply, accepted) in [
12602            (
12603                "TITLE: fix: retries\nAdd retries for failed requests.",
12604                true,
12605            ),
12606            (
12607                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12608                false,
12609            ),
12610            (
12611                "TITLE: fix: retries\nAdd retries for failed requests. Use token=secret",
12612                true,
12613            ),
12614            (
12615                "TITLE: fix: retries\nAdd retries for failed requests. Example: password=\"prefix\\\"secret tail\"",
12616                true,
12617            ),
12618            (
12619                "TITLE: fix: retries token=secret\nAdd retries for failed requests. Example: password=\"日本語の秘密secret\"",
12620                true,
12621            ),
12622            (
12623                "TITLE: fix: retries\n日本語の説明です。 token=secret",
12624                false,
12625            ),
12626            ("TITLE: fix: retries\n", false),
12627        ] {
12628            let dir = tempfile::tempdir().unwrap();
12629            let mut runner = runner_at(RunStatus::Gating);
12630            runner.state = state_with_summary(
12631                "add retries",
12632                "TITLE: fix: retries\n日本語の説明を書きます。",
12633            );
12634            runner.state.repo = dir.path().to_owned();
12635            runner.state.candidates[0].worktree = dir.path().to_owned();
12636            let mut author = spec("alpha");
12637            author.command = vec![
12638                "sh".into(),
12639                "-c".into(),
12640                "printf '%s' \"$REWRITE_REPLY\"".into(),
12641            ];
12642            author.env.insert("REWRITE_REPLY".into(), reply.into());
12643            runner.state.config.agents = vec![author];
12644            let winner = runner.state.candidates[0].clone();
12645            let first = runner
12646                .guarded_pr_message(&winner, None, true)
12647                .await
12648                .unwrap();
12649            let message = if accepted {
12650                first.expect("a passing rewrite never asks")
12651            } else {
12652                // Withheld: the run parks on a question and posts nothing yet.
12653                assert!(first.is_none());
12654                assert!(runner.state.parked);
12655                let store = ask::Questions::open();
12656                let asked: Vec<_> = store
12657                    .list()
12658                    .into_iter()
12659                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12660                    .collect();
12661                assert_eq!(asked.len(), 1);
12662                assert!(!asked[0].detail.contains("secret"));
12663                let rewrite = runner.state.github_text.as_ref().unwrap().rewrite.as_ref();
12664                assert_eq!(rewrite.is_some(), !reply.ends_with('\n'));
12665                assert_eq!(
12666                    asked[0]
12667                        .choices
12668                        .contains(&crate::github_text::USE_REWRITE.to_owned()),
12669                    rewrite.is_some()
12670                );
12671                // Silence: the question lapses and the neutral text is used,
12672                // without a second rewrite call or a second question.
12673                store
12674                    .update(&asked[0].id, |q| {
12675                        q.abandon("test");
12676                        Ok(())
12677                    })
12678                    .unwrap();
12679                let again = runner
12680                    .guarded_pr_message(&winner, None, true)
12681                    .await
12682                    .unwrap()
12683                    .expect("an abandoned question falls back");
12684                let n = store
12685                    .list()
12686                    .into_iter()
12687                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12688                    .count();
12689                assert_eq!(n, 1, "asked once per rejected text");
12690                again
12691            };
12692            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12693            assert!(!message.body.contains("secret"));
12694            assert!(!message.body.contains("prefix"));
12695            assert!(!message.title.contains("secret"));
12696            assert_eq!(
12697                message.body.contains("Add retries for failed requests."),
12698                accepted
12699            );
12700            assert_eq!(
12701                runner.state.seats["impl-A"].turns, 1,
12702                "only one rewrite invocation"
12703            );
12704            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12705            if accepted {
12706                assert!(runner.state.github_text.is_none());
12707                assert!(!runner.state.parked);
12708                assert!(!message.body.contains(crate::github_text::NEUTRAL_BODY));
12709                assert!(
12710                    message
12711                        .body
12712                        .contains(&format!("magi:run/{}", runner.state.id))
12713                );
12714            }
12715            if !accepted {
12716                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12717                assert!(
12718                    message
12719                        .body
12720                        .contains(&format!("magi:run/{}", runner.state.id))
12721                );
12722            }
12723        }
12724    }
12725
12726    #[tokio::test]
12727    async fn github_text_rejected_rewrite_survives_retry_and_posts_verbatim() {
12728        crate::run::pin_test_home();
12729        let dir = tempfile::tempdir().unwrap();
12730        let mut runner = runner_at(RunStatus::Gating);
12731        runner.state =
12732            state_with_summary("add retries", "TITLE: 日本語のタイトル\n日本語の説明です。");
12733        runner.state.repo = dir.path().to_owned();
12734        runner.state.candidates[0].worktree = dir.path().to_owned();
12735        let mut author = spec("alpha");
12736        author.command = vec![
12737            "sh".into(),
12738            "-c".into(),
12739            "printf '%s' \"$REWRITE_REPLY\"".into(),
12740        ];
12741        author.env.insert(
12742            "REWRITE_REPLY".into(),
12743            format!(
12744                "TITLE: fix: rewritten title\n{}\n日本語の説明です。",
12745                "English sentence. ".repeat(600)
12746            ),
12747        );
12748        runner.state.config.agents = vec![author];
12749        let winner = runner.state.candidates[0].clone();
12750        assert!(
12751            runner
12752                .guarded_pr_message(&winner, None, true)
12753                .await
12754                .unwrap()
12755                .is_none()
12756        );
12757        let record = runner.state.github_text.clone().unwrap();
12758        let rewrite = record.rewrite.clone().unwrap();
12759        assert_eq!(rewrite.failed, ["body-language"]);
12760        assert!(
12761            rewrite
12762                .body
12763                .ends_with(&format!("magi:run/{}", runner.state.id))
12764        );
12765        let store = ask::Questions::open();
12766        let q = store.get(record.question.as_ref().unwrap()).unwrap();
12767        assert!(
12768            q.choices
12769                .contains(&crate::github_text::USE_REWRITE.to_owned())
12770        );
12771        for label in [
12772            "ORIGINAL",
12773            "FALLBACK",
12774            "REWRITE",
12775            "Rules still failed: body-language",
12776            "truncated",
12777        ] {
12778            assert!(q.detail.contains(label), "{label}");
12779        }
12780        let artifact = std::fs::read_to_string(crate::run::artifact_path(
12781            &runner.state,
12782            &crate::github_text::artifact_name(&record.fingerprint),
12783        ))
12784        .unwrap();
12785        assert!(artifact.contains(&rewrite.title) && artifact.contains(&rewrite.body));
12786        // Restart from serialized state, then retry after a bad owner title.
12787        runner.state =
12788            serde_json::from_str(&serde_json::to_string(&runner.state).unwrap()).unwrap();
12789        store
12790            .update(&q.id, |q| {
12791                q.say("まだ日本語")?;
12792                q.answer(ask::Answer::Choice(crate::github_text::USE_MY_TEXT.into()))
12793            })
12794            .unwrap();
12795        assert!(
12796            runner
12797                .guarded_pr_message(&winner, None, true)
12798                .await
12799                .unwrap()
12800                .is_none()
12801        );
12802        let retry = runner.state.github_text.as_ref().unwrap();
12803        assert_eq!(retry.rewrite.as_ref(), Some(&rewrite));
12804        let q = store.get(retry.question.as_ref().unwrap()).unwrap();
12805        assert!(q.detail.contains("REWRITE") && q.detail.contains(&rewrite.title));
12806        store
12807            .update(&q.id, |q| {
12808                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
12809            })
12810            .unwrap();
12811        let message = runner
12812            .guarded_pr_message(&winner, None, true)
12813            .await
12814            .unwrap()
12815            .unwrap();
12816        assert_eq!(message.title, rewrite.title);
12817        assert_eq!(message.body, rewrite.body);
12818        assert_eq!(
12819            runner.state.github_text.as_ref().unwrap().choice,
12820            Some(GithubTextChoice::Rewrite)
12821        );
12822        let again = runner
12823            .guarded_pr_message(&winner, None, true)
12824            .await
12825            .unwrap()
12826            .unwrap();
12827        assert_eq!(again.title, message.title);
12828        assert_eq!(again.body, message.body);
12829        assert_eq!(runner.state.seats["impl-A"].turns, 1);
12830        assert!(
12831            !runner
12832                .state
12833                .events
12834                .iter()
12835                .any(|e| e.message.contains(&rewrite.title)
12836                    || e.message.contains("English sentence."))
12837        );
12838    }
12839
12840    #[tokio::test]
12841    async fn github_text_rewrite_choice_without_saved_text_falls_back() {
12842        crate::run::pin_test_home();
12843        let dir = tempfile::tempdir().unwrap();
12844        let mut runner = runner_at(RunStatus::Gating);
12845        runner.state = state_with_summary("add retries", "TITLE: fix: retries\n日本語の説明です。");
12846        runner.state.repo = dir.path().to_owned();
12847        runner.state.candidates[0].worktree = dir.path().to_owned();
12848        let winner = runner.state.candidates[0].clone();
12849        assert!(
12850            runner
12851                .guarded_pr_message(&winner, None, true)
12852                .await
12853                .unwrap()
12854                .is_none()
12855        );
12856        let record = runner.state.github_text.as_ref().unwrap();
12857        assert!(record.rewrite.is_none());
12858        // Records written before the additive field deserialize with no rewrite.
12859        let mut old = serde_json::to_value(record).unwrap();
12860        old.as_object_mut().unwrap().remove("rewrite");
12861        let old: crate::run::GithubTextAsk = serde_json::from_value(old).unwrap();
12862        assert!(old.rewrite.is_none());
12863        let id = record.question.clone().unwrap();
12864        ask::Questions::open()
12865            .update(&id, |q| {
12866                q.choices.push(crate::github_text::USE_REWRITE.into());
12867                q.answer(ask::Answer::Choice(crate::github_text::USE_REWRITE.into()))
12868            })
12869            .unwrap();
12870        let message = runner
12871            .guarded_pr_message(&winner, None, true)
12872            .await
12873            .unwrap()
12874            .unwrap();
12875        assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12876        assert_eq!(
12877            runner.state.github_text.as_ref().unwrap().choice,
12878            Some(GithubTextChoice::Fallback)
12879        );
12880    }
12881
12882    #[tokio::test]
12883    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12884        crate::run::pin_test_home();
12885        let dir = tempfile::tempdir().unwrap();
12886        let mut runner = runner_at(RunStatus::Gating);
12887        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12888        runner.state.repo = dir.path().to_owned();
12889        runner.state.candidates[0].worktree = dir.path().to_owned();
12890        let winner = runner.state.candidates[0].clone();
12891        assert!(
12892            runner
12893                .guarded_pr_message(&winner, None, true)
12894                .await
12895                .unwrap()
12896                .is_none()
12897        );
12898        let store = ask::Questions::open();
12899        let find = |run: &str| {
12900            store
12901                .list()
12902                .into_iter()
12903                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12904                .unwrap()
12905        };
12906        let run = runner.state.id.clone();
12907        let q = find(&run);
12908        let fp = runner
12909            .state
12910            .github_text
12911            .as_ref()
12912            .unwrap()
12913            .fingerprint
12914            .clone();
12915        let name = crate::github_text::artifact_name(&fp);
12916        let path = crate::run::artifact_path(&runner.state, &name);
12917        assert!(q.detail.contains(&path.display().to_string()));
12918        assert!(q.detail.contains("title-language: title"));
12919        let saved = std::fs::read_to_string(&path).expect("artifact written");
12920        assert!(saved.contains("日本語のタイトル"));
12921        assert!(q.cwd.is_none());
12922        assert_eq!(q.choices, ["use fallback", "post original", "use my text"]);
12923        // A bad replacement is refused and asked about once more.
12924        let say = |id: &str, text: &str, choice: &str| {
12925            store
12926                .update(id, |q| {
12927                    q.thread.push(ask::Turn {
12928                        who: ask::Who::Operator,
12929                        body: text.to_owned(),
12930                        at: jiff::Timestamp::now(),
12931                        note: None,
12932                    });
12933                    q.answer(ask::Answer::Choice(choice.to_owned()))
12934                })
12935                .unwrap();
12936        };
12937        say(&q.id, "まだ日本語", "use my text");
12938        assert!(
12939            runner
12940                .guarded_pr_message(&winner, None, true)
12941                .await
12942                .unwrap()
12943                .is_none()
12944        );
12945        let second = find(&run);
12946        assert_ne!(second.id, q.id);
12947        assert!(second.detail.contains("did not pass"));
12948        // The retry still compares the original text; the rejected
12949        // replacement is named by category only.
12950        assert!(second.detail.contains("日本語のタイトル"));
12951        assert!(!second.detail.contains("まだ日本語"));
12952        assert!(second.detail.contains(&path.display().to_string()));
12953        say(&second.id, "fix: retry failed requests", "use my text");
12954        let message = runner
12955            .guarded_pr_message(&winner, None, true)
12956            .await
12957            .unwrap()
12958            .expect("a vetted replacement is posted");
12959        assert_eq!(message.title, "fix: retry failed requests");
12960        assert_eq!(
12961            runner
12962                .state
12963                .github_text
12964                .as_ref()
12965                .unwrap()
12966                .chosen_title
12967                .as_deref(),
12968            Some("fix: retry failed requests")
12969        );
12970        assert!(runner.state.github_text.as_ref().unwrap().resolved);
12971        // Resumed again: the saved decision is applied, nothing is re-asked.
12972        let again = runner
12973            .guarded_pr_message(&winner, None, true)
12974            .await
12975            .unwrap()
12976            .unwrap();
12977        assert_eq!(again.title, message.title);
12978        assert_eq!(
12979            store
12980                .list()
12981                .into_iter()
12982                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12983                .count(),
12984            2
12985        );
12986    }
12987
12988    #[test]
12989    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12990        // What magi itself writes stays English under any configured language,
12991        // so a future localisation of these headings fails here. (The agents'
12992        // own text is also checked by the posting gate.)
12993        let mut state = state_with_summary(
12994            "add retries",
12995            "TITLE: fix(web): batch reads\n- reads run.json once",
12996        );
12997        state.config.graph.language = "ja".to_owned();
12998        let m = pr_message(&state, 'A');
12999        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
13000        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
13001
13002        // The task is the operator's own text: it goes in untouched, and the
13003        // fallback title (no summary) may be in its language too.
13004        let task = "今回やってほしいこと: results projector を直す";
13005        let mut state = state_with_summary(task, "- no title line");
13006        state.config.graph.language = "ja".to_owned();
13007        let m = pr_message(&state, 'A');
13008        assert_eq!(
13009            m.title,
13010            format!("chore: land candidate A of run {}", state.id)
13011        );
13012        assert!(
13013            m.body.contains(&format!(
13014                "<summary>Original task</summary>\n\n{task}\n\n</details>"
13015            )),
13016            "{}",
13017            m.body
13018        );
13019    }
13020
13021    #[test]
13022    fn pr_message_scrubs_home_paths_and_addresses() {
13023        let state = state_with_summary(
13024            "fix it in /Users/someone/src/x",
13025            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
13026        );
13027        let m = pr_message(&state, 'A');
13028        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
13029            assert!(!m.body.contains(leak), "{}", m.body);
13030        }
13031        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
13032    }
13033
13034    #[test]
13035    fn pr_message_survives_a_task_that_closes_details() {
13036        let state = state_with_summary("a </details> b", "TITLE: fix: x");
13037        let m = pr_message(&state, 'A');
13038        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
13039    }
13040
13041    #[test]
13042    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
13043        let cmd = manual_merge_command(
13044            MergeStyle::Squash,
13045            Path::new("/repo"),
13046            "b",
13047            "fix: \"quoted\" $(x) `y`\n\nbody",
13048        );
13049        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
13050    }
13051
13052    #[test]
13053    fn manual_merge_command_matches_the_configured_style() {
13054        let repo = Path::new("/repo");
13055        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
13056
13057        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
13058        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
13059
13060        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
13061        assert_eq!(
13062            squash,
13063            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
13064             \"Merge magi run 0832 (candidate A)\""
13065        );
13066
13067        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
13068        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
13069    }
13070
13071    #[test]
13072    fn a_nudge_gets_a_quarter_of_the_budget() {
13073        // The judge and implement budgets magi ships with.
13074        assert_eq!(retry_budget(secs(1200), true), secs(300));
13075        assert_eq!(retry_budget(secs(3600), true), secs(900));
13076    }
13077
13078    #[test]
13079    fn a_resent_prompt_keeps_the_whole_budget() {
13080        // The seat kept no context, so the retry is the original job again and
13081        // shortening it would only guarantee a second failure.
13082        assert_eq!(retry_budget(secs(1200), false), secs(1200));
13083        assert_eq!(retry_budget(secs(60), false), secs(60));
13084    }
13085
13086    #[test]
13087    fn the_floor_never_exceeds_the_original_budget() {
13088        // A short configured timeout must not be *raised* by the floor: the
13089        // operator asked for a bound, and a retry may not outlast the attempt
13090        // it is retrying.
13091        assert_eq!(retry_budget(secs(60), true), secs(60));
13092        assert_eq!(retry_budget(secs(480), true), secs(120));
13093        assert_eq!(retry_budget(secs(0), true), secs(0));
13094    }
13095
13096    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
13097        agent::CommandEvidence {
13098            id: "item1".to_owned(),
13099            description: "cargo test".to_owned(),
13100            exit_code,
13101            result_summary: String::new(),
13102            source: "codex".to_owned(),
13103        }
13104    }
13105
13106    #[test]
13107    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
13108        // No evidence is not the same fact as unconfirmed evidence: a
13109        // backend with no adapter, or a reply that ran no commands at all,
13110        // must not be misread as carrying a dangling job.
13111        assert!(!has_unconfirmed_command(&[]));
13112    }
13113
13114    #[test]
13115    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
13116        // Deliberately not a check on the exit code's *value*: a fixer
13117        // legitimately runs something that fails mid-iteration before it
13118        // succeeds, and that must never by itself reopen a valid report.
13119        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
13120        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
13121        assert!(!has_unconfirmed_command(&[
13122            evidence(Some(0)),
13123            evidence(Some(101))
13124        ]));
13125    }
13126
13127    #[test]
13128    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
13129        assert!(has_unconfirmed_command(&[
13130            evidence(Some(0)),
13131            evidence(None)
13132        ]));
13133    }
13134
13135    #[test]
13136    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
13137        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
13138        assert_eq!(
13139            verified_noop_claim(true, &[], text).as_deref(),
13140            Some("already fixed by b32cfc4, on main.")
13141        );
13142    }
13143
13144    #[test]
13145    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
13146        // A timeout or a bad exit code reads as the ordinary loss it is,
13147        // whatever the reply's own prose claims.
13148        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
13149        assert!(verified_noop_claim(false, &[], text).is_none());
13150    }
13151
13152    #[test]
13153    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
13154        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
13155        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
13156        // A confirmed command alongside the marker is fine.
13157        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
13158    }
13159
13160    #[test]
13161    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
13162        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
13163    }
13164
13165    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
13166    /// pair, in order, labelled A, B, C, ...
13167    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
13168        runner.state.candidates = shape
13169            .iter()
13170            .enumerate()
13171            .map(|(i, &(empty, verified))| Candidate {
13172                index: i,
13173                label: (b'A' + i as u8) as char,
13174                agent: "sonnet".to_owned(),
13175                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
13176                worktree: PathBuf::from(format!("/wt/{i}")),
13177                summary: String::new(),
13178                stat: String::new(),
13179                files: 0,
13180                commits: 0,
13181                empty,
13182                failed: None,
13183                verified_noop: verified.map(str::to_owned),
13184                duration_ms: 0,
13185                folded: false,
13186            })
13187            .collect();
13188    }
13189
13190    #[test]
13191    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
13192        ask_test_home();
13193        let mut runner = runner_at(RunStatus::Implementing);
13194        set_candidates(
13195            &mut runner,
13196            &[
13197                (true, Some("already on main at b32cfc4")),
13198                (true, Some("same fix, see the existing test")),
13199            ],
13200        );
13201
13202        runner
13203            .after_implement()
13204            .expect("a verified no-op is not an error");
13205
13206        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
13207    }
13208
13209    #[test]
13210    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
13211        ask_test_home();
13212        let mut runner = runner_at(RunStatus::Implementing);
13213        // Candidate A declares a verified no-op; candidate B simply wrote
13214        // nothing and said nothing about why. One candidate's claim is not
13215        // the whole run's agreement.
13216        set_candidates(
13217            &mut runner,
13218            &[(true, Some("already on main at b32cfc4")), (true, None)],
13219        );
13220
13221        let err = runner
13222            .after_implement()
13223            .expect_err("an unverified empty candidate must still fail the run");
13224
13225        assert!(
13226            err.to_string().contains("no candidate produced a change"),
13227            "{err}"
13228        );
13229        assert_eq!(runner.state.status, RunStatus::Failed);
13230    }
13231
13232    #[test]
13233    fn after_implement_still_fails_an_ordinary_all_empty_run() {
13234        ask_test_home();
13235        let mut runner = runner_at(RunStatus::Implementing);
13236        set_candidates(&mut runner, &[(true, None), (true, None)]);
13237
13238        let err = runner
13239            .after_implement()
13240            .expect_err("no candidate declared anything; this is an ordinary failure");
13241
13242        assert!(
13243            err.to_string().contains("no candidate produced a change"),
13244            "{err}"
13245        );
13246        assert_eq!(runner.state.status, RunStatus::Failed);
13247    }
13248}
13249
13250/// A graft rollback whose restore could not be confirmed (see
13251/// [`Runner::graft`]).
13252#[derive(Debug)]
13253struct GraftRestoreFailed(String);
13254
13255impl std::fmt::Display for GraftRestoreFailed {
13256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13257        write!(
13258            f,
13259            "could not restore the winner to its pre-graft commit: {}",
13260            self.0
13261        )
13262    }
13263}
13264
13265impl std::error::Error for GraftRestoreFailed {}