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::land;
41use crate::proc::Quiet as _;
42use crate::prompt::{
43    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
44};
45use crate::queue;
46use crate::refs;
47use crate::run::{
48    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
49    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
50    JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
51    OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
52    RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
53};
54use crate::verdict::{
55    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
56    ReviewVote, Severity,
57};
58use crate::worktree_setup;
59
60/// How much verification output is kept and fed back to the fixer.
61const OUTPUT_TAIL: usize = 8_000;
62
63/// Bytes of a failing command's output kept in an event, so the reason a run
64/// stopped is readable from the report without opening `run.json`.
65const EVENT_OUTPUT_TAIL: usize = 2_000;
66
67/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
68/// command's pid before releasing the build cache's lease.
69const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
70
71/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
72/// command's pid to actually exit before giving up and releasing anyway.
73///
74/// A timeout means the process was asked to die (`kill_on_drop`,
75/// `start_kill`), not that it already has — on Windows in particular that can
76/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
77/// the instant the command returns would let the very next acquirer (this
78/// run's own next round, another run's verification, the janitor's prune)
79/// start touching the same directory while it might still be writing to it,
80/// so this polls the actual pid — real confirmation, not a fixed guess —
81/// until it is gone or this ceiling is reached. It is still not full
82/// process-tree reaping: a grandchild the timed-out process spawned and that
83/// outlives it independently is invisible to a pid check, and continuing to
84/// observe and collect *that* stays a different piece of work with its own
85/// owner. Set generously because the common case returns early the moment
86/// the pid is confirmed gone, not because every timeout pays this in full.
87const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
88
89/// Consecutive review rounds with no tree progress (see
90/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
91/// instead of spending the rest of the round budget.
92///
93/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
94/// legitimately finds nothing left to change (its previous round's fix already
95/// covered it, and this round's reviewers re-raised only nits) looks the same
96/// as one that is spinning, for exactly one round. Two in a row is where the
97/// two stop being distinguishable, and a review round on this workload has
98/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
99/// third attempt at a tree that has not moved twice running is pure cost.
100/// This does not touch `review_rounds` itself, which stays the operator's
101/// call.
102pub(crate) const STAGNANT_LIMIT: usize = 2;
103
104/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
105/// a base that moved before giving up and leaving the run `Blocked` for a
106/// person.
107///
108/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
109/// that keeps moving faster than a run can catch it is not something more
110/// rebasing fixes, it is a person's call. Not the same *number as*
111/// `land_rounds` - this budget is spent before a pull request exists, land's
112/// after - but bounded for the identical reason, so it uses the same
113/// default. Counted across both call sites in [`Runner::finish_after_tally`]
114/// (once before review, once before the gate), because either one finding
115/// the base still moving is the same signal.
116const BASE_SYNC_ROUNDS: usize = 4;
117
118/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
119/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
120/// reply held no [`FixReport`].
121///
122/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
123/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
124/// "I'll pause here until the `cargo make check` background run reports
125/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
126/// No `FixReport` was ever collected from that seat, and the run moved on to
127/// the next review round regardless.
128///
129/// Bounded independently of `review_rounds` and `graph.retries`: this
130/// recovers one seat's missing report mid-round, not a new round of review or
131/// an ordinary parse retry, and must not itself become the unbounded wait the
132/// rest of this module exists to avoid.
133const MAX_FIX_CONTINUATIONS: usize = 2;
134
135/// One queued agent invocation.
136///
137/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
138/// CLI hung up on its own stream is asked again from the same job rather than
139/// rebuilt from scratch. See [`Runner::resume_undelivered`].
140#[derive(Clone)]
141struct SeatJob {
142    spec: AgentSpec,
143    seat: SeatState,
144    cwd: PathBuf,
145    prompt: String,
146    timeout: Duration,
147    allow_write: bool,
148    sessions: bool,
149    artifacts: PathBuf,
150    stem: String,
151    /// The prompt for a seat that has been handed to another roster agent
152    /// (a fresh session): everything the original seat would have
153    /// remembered. `None` when `prompt` already carries it, as the first
154    /// ranking and the implement prompt do. Never a resume-style prompt.
155    handover: Option<String>,
156}
157
158/// How the graph reads one agent invocation.
159///
160/// Quota is split out from an ordinary failure on purpose: a rate-limited call
161/// is known to fail again if retried now, so the retry loop must not spend an
162/// attempt on it. `Dropped` is split out for the opposite reason: unlike
163/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
164/// error JSON, never the agent's answer — a caller that matched only
165/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
166/// left to read that JSON as if it were usable output. `resume_undelivered`
167/// is the only caller that acts on it; everywhere else it is reported like an
168/// ordinary failure.
169enum AgentOutcome {
170    /// A usable output.
171    Ok(AgentOutput),
172    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
173    Quota(AgentOutput),
174    /// The CLI hung up on its own stream after billed work. See
175    /// [`agent::AgentOutput::work_undelivered`].
176    Dropped(AgentOutput),
177    /// Any other failure: a timeout, a bad exit code, an empty reply.
178    Failed(String),
179}
180
181/// A request to park the run at its next node boundary.
182///
183/// Cloning is how the request travels: the loop keeps one handle and hands a
184/// clone to each [`Runner`], and every clone points at the same flag. There
185/// is no channel because there is nothing to send - the only message is
186/// "park", it is idempotent, and a flag cannot be missed by a receiver that
187/// was not listening yet.
188///
189/// The boundary is what makes this cheap. Every node writes the run's state
190/// before the next one starts, and every node skips what is already recorded:
191/// `prep` returns early once candidates exist, `implement` asks only the seats
192/// with nothing on disk, `judge` returns early once judgements exist. So a
193/// parked run resumes into exactly the node it stopped before, and no agent
194/// work is thrown away. Killing the process mid-node, by contrast, loses
195/// whatever the seats in flight had not yet written - which for an implement
196/// wave is an hour of paid work.
197///
198/// A [`Runner`] watches two independent handles of this type - see
199/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
200/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
201/// clone covering the whole daemon's lifetime and is never asked to un-park,
202/// which is correct exactly because nothing is dispatched after it fires.
203/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
204/// that parks for an interrupted task must go on to run other tasks
205/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
206/// reusing the daemon-wide one.
207#[derive(Debug, Clone, Default)]
208pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
209
210impl Pause {
211    /// A pause nobody has asked for yet.
212    #[must_use]
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Ask the run to park at its next node boundary. Idempotent.
218    pub fn park(&self) {
219        self.0.store(true, Ordering::SeqCst);
220    }
221
222    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
223    /// fold into the run's own `park` event - so an operator reading the run
224    /// later knows this was a deliberate interrupt rather than a shutdown or
225    /// a binary swap. The first reason recorded wins; a park already in
226    /// flight is not relabelled by a second, unrelated request.
227    pub fn park_because(&self, reason: impl Into<String>) {
228        let mut reason_guard = self
229            .1
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        if reason_guard.is_none() {
233            *reason_guard = Some(reason.into());
234        }
235        drop(reason_guard);
236        self.park();
237    }
238
239    /// Has a park been asked for?
240    #[must_use]
241    pub fn parked(&self) -> bool {
242        self.0.load(Ordering::SeqCst)
243    }
244
245    /// Why the park was asked for, when the caller used [`Pause::park_because`].
246    #[must_use]
247    pub fn reason(&self) -> Option<String> {
248        self.1
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner)
251            .clone()
252    }
253}
254
255/// Drives one run.
256pub struct Runner {
257    /// Run state; public so the CLI can report on it.
258    pub state: RunState,
259    roles: ResolvedRoles,
260    sem: Arc<Semaphore>,
261    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
262    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
263    /// this is never the same handle as `interrupt`.
264    pause: Pause,
265    /// Set when `magi serve`'s interrupt scheduler wants this specific run
266    /// parked at its next node boundary, to let a task marked
267    /// [`crate::queue::Task::interrupt`] run alone before this one carries
268    /// on. Unlike `pause`, a fresh, unshared handle per run - see
269    /// [`Runner::watch_interrupt`].
270    interrupt: Pause,
271}
272
273/// Where the branch's own commits start: its merge base with the base branch
274/// as the remote has it now, else with the recorded `base_commit`. A branch
275/// rebased onto a base that moved past `base_commit` would otherwise count
276/// the base's commits as its own under `base_commit..branch`.
277async fn review_base(
278    repo: &Path,
279    remote: &str,
280    base_branch: &str,
281    base_commit: &str,
282    branch: &str,
283) -> String {
284    review_base_checked(repo, remote, base_branch, base_commit, branch)
285        .await
286        .0
287}
288
289/// [`review_base`] plus whether the base was read from a freshly fetched
290/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
291/// never older than `base_commit`'s view of the base), but the answer is then
292/// not trusted to rewrite a pull request's title.
293async fn review_base_checked(
294    repo: &Path,
295    remote: &str,
296    base_branch: &str,
297    base_commit: &str,
298    branch: &str,
299) -> (String, bool) {
300    let tracking = format!("{remote}/{base_branch}");
301    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
302    if git::rev_exists(repo, &tracking).await
303        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
304        && !mb.is_empty()
305    {
306        return (mb, fresh);
307    }
308    let mb = git::merge_base(repo, base_commit, branch)
309        .await
310        .ok()
311        .filter(|mb| !mb.is_empty())
312        .unwrap_or_else(|| base_commit.to_owned());
313    (mb, false)
314}
315
316/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
317/// when git cannot say or finds nothing: a stale list is better than a wrong
318/// or empty one.
319pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
320    if !is_review_run(state) {
321        return;
322    }
323    let base = review_base(
324        &state.repo,
325        &state.config.merge.remote,
326        &state.base_branch,
327        &state.base_commit,
328        branch,
329    )
330    .await;
331    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
332        && !subjects.is_empty()
333        && state.reviewed_commits.as_ref() != Some(&subjects)
334    {
335        state.reviewed_commits = Some(subjects);
336        state.save().ok();
337    }
338}
339
340/// Subjects of the base's commits between the recorded start and the branch's
341/// merge base: what a stale `base_commit..branch` would have mistaken for the
342/// branch's own work.
343async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
344    let (base, trusted) = review_base_checked(
345        &state.repo,
346        &state.config.merge.remote,
347        &state.base_branch,
348        &state.base_commit,
349        branch,
350    )
351    .await;
352    if !trusted {
353        return None;
354    }
355    git::subjects(&state.repo, &state.base_commit, &base)
356        .await
357        .ok()
358}
359
360/// May an adopted pull request's title be replaced with `computed`? Only when
361/// it is empty, magi's own shape, or a base commit's subject that leaked in;
362/// a title a person wrote stays. Never when `computed` is itself a leak.
363fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
364    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
365    if is_leak(computed) {
366        return false;
367    }
368    let cur = current.trim();
369    cur.is_empty()
370        || cur.starts_with(REVIEW_PROMPT_OPENING)
371        || cur.starts_with("chore: land candidate")
372        || cur.starts_with("magi: candidate")
373        || is_leak(cur)
374}
375
376/// The commit a run branches from: the base branch as the remote has it.
377///
378/// Two failures this replaces. A run used to branch off `HEAD` and so refused
379/// to start on a dirty tree, which made `magi serve` decline every task for as
380/// long as the operator had work in progress - most of the time. Branching off
381/// the *local* base branch fixed that and introduced a worse one: `land` merges
382/// the winner on GitHub, nothing updates the local ref, and the next run
383/// branches off a base missing everything the previous runs landed. Two tasks
384/// in a row from a phone would have had the second silently re-implementing
385/// against stale code and opening a pull request that reverted the first.
386///
387/// Only refs move here - no checkout, no local branch, no merge - so it is safe
388/// with uncommitted work in the tree. A machine with no network still starts:
389/// the fetch may fail and the local tip is used with a warning, because
390/// refusing to run offline is a worse failure than running against a base the
391/// operator can see for themselves.
392///
393/// One function, called by both entry points. Two answers to "where does a run
394/// branch from" is the kind of drift nobody notices until a diff is wrong.
395/// Bring the local `branch` in line with `<remote>/<branch>` before a review
396/// checks it out.
397///
398/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
399/// anything other than plain `git push` from this checkout (a jj colocated
400/// workspace, another clone) moves only the remote-tracking ref - so the local
401/// one can be a stale placeholder. It moves only when local is behind the remote or is an
402/// empty placeholder that diverged from it; unpushed local work is kept, and a real
403/// divergence is refused rather than guessed at.
404async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
405    let tracking = format!("{remote}/{branch}");
406    let fetched = git::fetch(repo, remote, branch).await;
407    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
408    let local_exists = git::branch_exists(repo, branch).await?;
409    if !fresh {
410        if !local_exists {
411            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
412        }
413        tracing::warn!(
414            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
415        );
416        return Ok(());
417    }
418    let remote_sha = git::rev_parse(repo, &tracking).await?;
419    if !local_exists {
420        git::git(repo, &["branch", branch, &tracking]).await?;
421        return Ok(());
422    }
423    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
424    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
425        return Ok(());
426    }
427    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
428        // Diverged. `reconcile` settles it only when it can prove nothing is
429        // lost: a local tip that is the remote's change rebased is pushed over
430        // it (lease pinned to the tip read here), a tip whose every extra
431        // commit is empty is a placeholder the remote's work replaced, and
432        // anything else is two different changes - a question for a person.
433        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
434            .await?
435        {
436            crate::reconcile::Reconciliation::Pushed => {
437                tracing::warn!(
438                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
439                    short(&local_sha),
440                    short(&remote_sha)
441                );
442                return Ok(());
443            }
444            crate::reconcile::Reconciliation::Placeholder => {}
445            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
446        }
447    }
448    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
449    if !out.ok() {
450        bail!(
451            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
452            short(&local_sha),
453            short(&remote_sha),
454            out.stderr
455        );
456    }
457    tracing::warn!(
458        "local `{branch}` was stale: fast-forwarded {} -> {}",
459        short(&local_sha),
460        short(&remote_sha)
461    );
462    Ok(())
463}
464
465async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
466    let tracking = format!("{remote}/{base_branch}");
467    let fetched = git::fetch(repo, remote, base_branch).await;
468    if let Ok(out) = &fetched
469        && out.ok()
470        && git::rev_exists(repo, &tracking).await
471    {
472        return git::rev_parse(repo, &tracking).await;
473    }
474    let why = match &fetched {
475        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
476        Ok(_) => format!("{remote} has no {base_branch}"),
477        Err(e) => e.to_string(),
478    };
479    // No fallback to the local branch: it may be behind, and branching off an
480    // old commit is the stale-checkout bug this check exists to prevent.
481    bail!(
482        "cannot read {tracking} ({why}); refusing to branch off the local \
483         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
484         base / remote in magi.toml"
485    )
486}
487
488/// Exclusive claim on one run's `magi fix` step, released on drop — including
489/// on an early return or a panic.
490///
491/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
492/// manual `magi fix` invocations against the same run are otherwise
493/// invisible to each other and would race to remove and recreate the same
494/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
495/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
496/// which is only ever reclaimed later, out of band, by
497/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
498/// `magi fix` invocation is not necessarily running under either of those, so
499/// nothing would ever sweep a lock a killed or crashed process left behind.
500/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
501/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
502/// lease use: an unreadable or unparsable pid, or a liveness query the
503/// platform cannot answer, reads as alive and the lock is left in place.
504struct FixClaim {
505    path: PathBuf,
506}
507
508impl FixClaim {
509    fn acquire(dir: &Path) -> Result<Self> {
510        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
511        let path = dir.join("fix.lock");
512        match Self::create(&path) {
513            Ok(claim) => Ok(claim),
514            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
515                if Self::reclaim_if_dead(&path) {
516                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
517                } else {
518                    bail!(
519                        "another `magi fix` is already running for this run ({} exists)",
520                        path.display()
521                    )
522                }
523            }
524            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
525        }
526    }
527
528    fn create(path: &Path) -> std::io::Result<Self> {
529        let mut f = std::fs::OpenOptions::new()
530            .write(true)
531            .create_new(true)
532            .open(path)?;
533        use std::io::Write as _;
534        // Read back by `reclaim_if_dead` on a later, stuck invocation.
535        writeln!(f, "{}", std::process::id())?;
536        Ok(Self {
537            path: path.to_owned(),
538        })
539    }
540
541    /// True if the lock named a process confirmed dead, in which case it was
542    /// also removed. Never true on an unreadable file, an unparsable pid, or
543    /// a liveness query the platform cannot answer — see this type's own doc.
544    fn reclaim_if_dead(path: &Path) -> bool {
545        let dead = std::fs::read_to_string(path)
546            .ok()
547            .and_then(|body| body.trim().parse::<u32>().ok())
548            .is_some_and(|pid| !crate::proc::pid_alive(pid));
549        dead && std::fs::remove_file(path).is_ok()
550    }
551}
552
553impl Drop for FixClaim {
554    fn drop(&mut self) {
555        let _ = std::fs::remove_file(&self.path);
556    }
557}
558
559impl Runner {
560    /// Start a fresh run against `repo`.
561    pub async fn start(
562        repo: &Path,
563        instruction: String,
564        config: Config,
565        origin: Origin,
566    ) -> Result<Self> {
567        Self::start_naming(repo, instruction, "", config, origin).await
568    }
569
570    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
571    /// searched for branch and commit references along with the instruction,
572    /// since a task may name the work it is about only in its title.
573    pub async fn start_naming(
574        repo: &Path,
575        instruction: String,
576        also_scan: &str,
577        config: Config,
578        origin: Origin,
579    ) -> Result<Self> {
580        let repo = git::toplevel(repo).await?;
581        let missing = agent::missing_programs(&config.agents);
582        if !missing.is_empty() {
583            bail!(
584                "these agent programs are not on PATH: {}. Fix the roster in \
585                 magi.toml or install them.",
586                missing.join(", ")
587            );
588        }
589        let base_branch =
590            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
591                .await?;
592        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
593        let roles = config.resolve_roles()?;
594        let max_parallel = config.graph.max_parallel.max(1);
595        // A task that points at work already in the repository starts from
596        // it; what the repository says about each reference is recorded.
597        let seeds = refs::resolve(
598            &repo,
599            &base_commit,
600            &config.merge.remote,
601            &format!("{also_scan}\n{instruction}"),
602        )
603        .await;
604        refs::plan(&repo, &seeds).await?;
605        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
606        // Recorded before the first save, so a crash right after minting
607        // cannot leave a run with no origin. Legibility only: nothing reads it
608        // to decide anything.
609        state.origin = Some(origin);
610        for seed in &seeds {
611            state.event(
612                "seed",
613                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
614            );
615        }
616        state.seeds = seeds;
617        state.event("start", format!("run {} created", state.id));
618        state.save()?;
619        Ok(Self {
620            state,
621            roles,
622            sem: Arc::new(Semaphore::new(max_parallel)),
623            pause: Pause::new(),
624            interrupt: Pause::new(),
625        })
626    }
627
628    /// Open a review-only run against work that already exists on `branch`.
629    ///
630    /// The expensive half of the graph is the implement wave — measured at
631    /// 111 and 134 internal tool-loop turns on this repository, against a
632    /// handful for a judge or a reviewer. The cheap half is worth running on
633    /// hand-written work too, and there was no way to reach it.
634    ///
635    /// No new state and no schema change are needed: a run with **one** viable
636    /// candidate and a tally already decided degrades `execute` to exactly
637    /// review → gate → merge, because `judge` skips a single-candidate field,
638    /// `deliberate` has fewer than two first choices to reconcile, `vote`
639    /// returns early, `tally` is already present and `fold_losers` has no
640    /// losers. Resuming such a run therefore does the right thing as well.
641    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
642        Self::review_taking_over(repo, branch, config, None, origin).await
643    }
644
645    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
646    /// at the same task still has `branch` checked out, its worktree is
647    /// released first if that is safe (see [`crate::handover`]), and the
648    /// review refuses with the reason if it is not. `None` is a hand-run
649    /// review: it has no earlier attempts, so only a worktree of a dead run
650    /// magi recorded itself can be released.
651    pub async fn review_taking_over(
652        repo: &Path,
653        branch: &str,
654        config: Config,
655        takeover: Option<crate::handover::Takeover>,
656        origin: Origin,
657    ) -> Result<Self> {
658        let repo = git::toplevel(repo).await?;
659        let missing = agent::missing_programs(&config.agents);
660        if !missing.is_empty() {
661            bail!(
662                "these agent programs are not on PATH: {}. Fix the roster in \
663                 magi.toml or install them.",
664                missing.join(", ")
665            );
666        }
667        let base_branch =
668            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
669                .await?;
670        if base_branch == branch {
671            bail!("`{branch}` is the base branch; there is nothing to review against");
672        }
673        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
674
675        let roles = config.resolve_roles()?;
676        let max_parallel = config.graph.max_parallel.max(1);
677        let mut state = RunState::new(
678            repo.clone(),
679            base_branch,
680            base_commit.clone(),
681            String::new(),
682            config,
683        );
684        state.origin = Some(origin);
685
686        // Released before anything else touches the branch: a stale local
687        // branch is moved with `git branch -f`, which git refuses while an
688        // earlier attempt's worktree still has it checked out. Everything
689        // after this point that can fail puts the old run back.
690        // A hand-run review has no task, hence no earlier attempts, but a
691        // worktree of a dead run magi made may still be released.
692        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
693            earlier: Vec::new(),
694            home: crate::run::home(),
695            choice: None,
696        });
697        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
698        if let Some(released) = &released {
699            state.event(
700                "release",
701                format!(
702                    "took `{branch}` over from run {}: its worktree was released: {}",
703                    crate::run::short_of(&released.old_id),
704                    released.audit
705                ),
706            );
707        }
708        // The owner's answer to an earlier divergence question is applied
709        // here: after the release (git will not move a checked-out branch)
710        // and before the sync that would otherwise ask again.
711        if let Some(choice) = takeover.choice.as_ref()
712            && let Err(e) =
713                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
714                    .await
715        {
716            if let Some(released) = &released {
717                released.restore(&repo, branch).await;
718            }
719            return Err(e.context("applying the owner's answer about the diverged branch"));
720        }
721        let opened =
722            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
723        if opened.is_err()
724            && let Some(released) = &released
725        {
726            released.restore(&repo, branch).await;
727        }
728        opened
729    }
730
731    /// The half of [`Runner::review_taking_over`] that can fail after an
732    /// earlier attempt's worktree was released.
733    async fn open_review(
734        repo: &Path,
735        branch: &str,
736        mut state: RunState,
737        roles: ResolvedRoles,
738        max_parallel: usize,
739        base_commit: String,
740    ) -> Result<Self> {
741        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
742        // The commit subjects are the closest thing to a task statement that
743        // existing work carries, and the reviewers are told as much.
744        let start = review_base(
745            repo,
746            &state.config.merge.remote,
747            &state.base_branch,
748            &base_commit,
749            branch,
750        )
751        .await;
752        let log = git::log_oneline(repo, &start, branch)
753            .await
754            .unwrap_or_default();
755        let instruction = format!(
756            "Review the work already on branch `{branch}`. There is no task \
757             statement: what the change claims to do is whatever its commits \
758             say.\n\n{}",
759            if log.trim().is_empty() {
760                "(no commit messages)"
761            } else {
762                log.trim()
763            }
764        );
765        state.instruction = instruction;
766        state.reviewed_commits = Some(
767            git::subjects(repo, &start, branch)
768                .await
769                .unwrap_or_default(),
770        );
771
772        // An attached worktree, so the fixer's commits land on the branch under
773        // review rather than on a detached head nobody will look at again.
774        let worktree = state.worktree_root().join("under-review");
775        if let Some(parent) = worktree.parent() {
776            tokio::fs::create_dir_all(parent).await.ok();
777        }
778        let path = worktree.to_string_lossy().to_string();
779        git::git(repo, &["worktree", "add", &path, branch])
780            .await
781            .with_context(|| {
782                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
783            })?;
784
785        let setup = match ensure_setup_config(&mut state, repo).await {
786            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
787            Err(e) => Err(e),
788        };
789        if let Err(e) = setup {
790            git::worktree_remove(repo, &worktree).await.ok();
791            return Err(e);
792        }
793        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
794            .await
795            .unwrap_or(0);
796        if commits == 0 {
797            git::worktree_remove(repo, &worktree).await.ok();
798            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
799        }
800        let files = git::changed_files(&worktree, &base_commit, "HEAD")
801            .await
802            .map(|f| f.len())
803            .unwrap_or(0);
804        if files == 0
805            && let (Ok(head_tree), Ok(base_tree)) = (
806                git::tree_of(&worktree, "HEAD").await,
807                git::tree_of(&worktree, &base_commit).await,
808            )
809            && head_tree == base_tree
810        {
811            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
812            git::worktree_remove(repo, &worktree).await.ok();
813            bail!(
814                "`{branch}` at {} has a tree identical to base {}; this usually means \
815                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
816                 against `{}/{branch}`) rather than an empty change",
817                short(&head),
818                short(&base_commit),
819                state.config.merge.remote
820            );
821        }
822        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
823            .await
824            .unwrap_or_default();
825
826        state.candidates.push(Candidate {
827            index: 0,
828            label: 'A',
829            // Not an agent id on purpose: nothing in the roster wrote this, and
830            // the stats tables must not credit anyone with a win for it.
831            agent: EXISTING_BRANCH.to_owned(),
832            branch: branch.to_owned(),
833            worktree,
834            summary: String::new(),
835            stat,
836            files,
837            commits,
838            empty: false,
839            failed: None,
840            verified_noop: None,
841            duration_ms: 0,
842            folded: false,
843        });
844        state.tally = Some(Tally {
845            first_choice: BTreeMap::from([('A', 0)]),
846            borda: BTreeMap::new(),
847            winner: 'A',
848            rankings: 0,
849            unanimous_initial: false,
850            deliberated: false,
851            changed_votes: 0,
852            unanimous_final: false,
853            tie_break: None,
854            // No panel sat, so no quorum applies. Zero judges is the correct
855            // number for work that never competed, and must not be reported as
856            // a collapsed panel.
857            judges: 0,
858            present: 0,
859            quorum: 0,
860            met_quorum: true,
861            uncontested: Some("review-only run: nothing competed".to_owned()),
862        });
863        state.status = RunStatus::Reviewing;
864        state.event(
865            "start",
866            format!(
867                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
868                state.id
869            ),
870        );
871        state.save()?;
872        Ok(Self {
873            state,
874            roles,
875            sem: Arc::new(Semaphore::new(max_parallel)),
876            pause: Pause::new(),
877            interrupt: Pause::new(),
878        })
879    }
880
881    /// Reopen an existing run.
882    pub fn resume(id: &str) -> Result<Self> {
883        let state = RunState::load(id)?;
884        if let Some(to) = &state.released_to {
885            bail!(
886                "run {} cannot be resumed: its worktree was released to run {}",
887                state.short(),
888                crate::run::short_of(to)
889            );
890        }
891        let roles = state.config.resolve_roles()?;
892        let max_parallel = state.config.graph.max_parallel.max(1);
893        Ok(Self {
894            state,
895            roles,
896            sem: Arc::new(Semaphore::new(max_parallel)),
897            pause: Pause::new(),
898            interrupt: Pause::new(),
899        })
900    }
901
902    /// Walk the graph to a terminal state, skipping nodes already recorded.
903    ///
904    /// Every way a run is driven - the queue loop, `magi run`, a resume from
905    /// the phone - ends here, so this is the one place a run that ended
906    /// Blocked / Stalled / Failed, or died with an error, is announced to the
907    /// notification centre. Best-effort: see [`crate::notices::raise`].
908    pub async fn execute(&mut self) -> Result<()> {
909        let result = self.execute_graph().await;
910        self.mark_driver_exited();
911        let ended = if result.is_err() {
912            Some(crate::notices::run_stopped(&self.state.id, &self.state))
913        } else {
914            crate::notices::run_ended(&self.state)
915        };
916        if let Some(notice) = ended {
917            crate::notices::raise(notice);
918        }
919        result
920    }
921
922    /// Record that this process no longer drives the run, so its pid (a
923    /// daemon's outlives the run) is not read as a live driver.
924    ///
925    /// Written onto the record as it is on disk, never this copy: another
926    /// process may have resumed the run (recording its own pid and clearing
927    /// the flag) or released its worktree since this copy was read, and
928    /// saving over that would mark a running driver dead. Only a record still
929    /// naming this process as the driver is touched.
930    fn mark_driver_exited(&mut self) {
931        self.state.driver_exited = true;
932        let pid = std::process::id();
933        let Ok(mut disk) = RunState::load(&self.state.id) else {
934            return;
935        };
936        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
937            return;
938        }
939        disk.driver_exited = true;
940        if let Err(e) = disk.save() {
941            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
942        }
943    }
944
945    async fn execute_graph(&mut self) -> Result<()> {
946        // Moving again, so it is no longer parked. Set before the walk rather
947        // than in `resume`, so every way of re-entering the graph clears it
948        // and a card cannot claim a run is waiting to be resumed while the
949        // agents are already working.
950        self.state.parked = false;
951        // Any seat this state still lists as answering belongs to whatever
952        // process last drove this run — this one included, if it crashed
953        // mid-wave. Cleared and flushed immediately, before anything else
954        // runs, so a resume can never show a seat as live when nothing is
955        // asking it anything yet; the node that actually dispatches the next
956        // wave repopulates it.
957        self.state.clear_active();
958        // Recorded in the same spot, and flushed together with the clear
959        // above: this is the pid a reader checks (`RunState::liveness`) when
960        // no daemon claim exists to answer "is a process still driving this
961        // run" — a plain `magi run` / `magi review` typed into a terminal
962        // claims nothing there. Always overwritten, never only-if-absent, so
963        // a resumed run's stale pid from a previous, possibly-dead process
964        // can never survive into this one's own report. Unlike
965        // `clear_active`, this changes on every single `execute()` call, so
966        // the save below is now unconditional rather than only-if-cleared.
967        //
968        // `driver_started_at` is recorded in the same breath, from this same
969        // pid, so `liveness` can tell a live pid that is genuinely still us
970        // apart from one the OS has since handed to an unrelated process —
971        // see that field's own doc for why the pid alone is not enough.
972        // A resume that raced a takeover: the record on disk says the worktree
973        // was handed to a later run after this copy was read. Saving over it
974        // would erase that and drive a run with nothing to run in.
975        if let Ok(disk) = RunState::load(&self.state.id)
976            && let Some(to) = &disk.released_to
977        {
978            bail!(
979                "run {} cannot continue: its worktree was released to run {}",
980                self.state.short(),
981                crate::run::short_of(to)
982            );
983        }
984        let pid = std::process::id();
985        self.state.driver_pid = Some(pid);
986        self.state.driver_started_at = crate::proc::process_started_at(pid);
987        self.state.driver_exited = false;
988        self.state.save()?;
989        // A run that already lost its quorum never resumes into the verdict
990        // machinery: `deliberate` and `vote` would otherwise clobber the
991        // stalled marker back to Voting and the run would keep going past a
992        // verdict that is no longer trustworthy. Everything already recorded is
993        // kept, so the run stays resumable (or foldable) for a human to pick up.
994        //
995        // On --resume the run gets one chance to repair itself: the seats a
996        // rate limit took out are re-asked. If their quota has since reset and
997        // the quorum is restored, the run picks up and finishes; otherwise it
998        // stays stale and still-resumable for a later retry. If it does not
999        // recover, the returned status stays `Stalled` and nothing was
1000        // clobbered (the recovery only mutates entries for the lost seats).
1001        if self.state.status == RunStatus::Stalled {
1002            if self.recover_stall().await? {
1003                self.finish_after_tally().await?;
1004            } else {
1005                // Still below quorum: persist the marker and stay resumable.
1006                self.state.save()?;
1007            }
1008            return Ok(());
1009        }
1010        // A run parked inside `land` - watching CI, mid fix-round, or
1011        // waiting on the owner's merge approval - resumes directly into it,
1012        // never back through `prep`. Everything before `merge` already
1013        // concluded; that is the only way `status` reaches `Landing` in the
1014        // first place. Re-walking `review_loop` first would also be actively
1015        // wrong: its own status recomputation (see its doc) treats any
1016        // clean round as reason to set `status` to `Gating`, which would
1017        // clobber this marker before `merge` ever ran, and this run would
1018        // never find its way back into `land` at all.
1019        if self.state.status == RunStatus::Landing {
1020            self.run_land().await?;
1021            // `run_land` may have settled the run right here - CI came back
1022            // green and the PR merged, say - without ever passing back
1023            // through `merge`'s own trailing call. Whatever it left `status`
1024            // as is what this has to read.
1025            self.settle_questions();
1026            return Ok(());
1027        }
1028        self.prep().await?;
1029        if self.park_here()? {
1030            return Ok(());
1031        }
1032        self.advise().await?;
1033        if self.park_here()? {
1034            return Ok(());
1035        }
1036        self.implement().await?;
1037        if self.park_here()? {
1038            return Ok(());
1039        }
1040        // `after_implement` already saved the state and settled any open
1041        // questions when it set this; nothing later in the graph has
1042        // anything to judge.
1043        if self.state.status == RunStatus::VerifiedNoop {
1044            return Ok(());
1045        }
1046        self.judge().await?;
1047        if self.park_here()? {
1048            return Ok(());
1049        }
1050        self.deliberate().await?;
1051        if self.park_here()? {
1052            return Ok(());
1053        }
1054        self.vote().await?;
1055        if self.park_here()? {
1056            return Ok(());
1057        }
1058        self.tally()?;
1059        // A verdict that lost its quorum is not trustworthy: do not review,
1060        // gate, or merge on it. Everything already done is kept, so the run
1061        // stays resumable (or foldable); the human can replace the agent that
1062        // ran out of quota and pick it up.
1063        if self.state.status == RunStatus::Stalled {
1064            // Persist the stalled marker now — the normal end-of-execute save
1065            // below is below this early return, and without it a resumed run
1066            // would reload a pre-tally status and keep going.
1067            self.state.save()?;
1068            return Ok(());
1069        }
1070        self.finish_after_tally().await?;
1071        Ok(())
1072    }
1073
1074    /// Park here if asked to, recording it in the run's own timeline.
1075    ///
1076    /// Returns whether the caller should stop walking the graph. The state is
1077    /// saved either way by the node that just finished; this adds the event so
1078    /// the operator's card says why a run that is neither finished nor moving
1079    /// is sitting where it is.
1080    fn park_here(&mut self) -> Result<bool> {
1081        // Either handle asking is enough - see `Pause`'s own doc for why
1082        // they are never the same one. `interrupt` is checked second so a
1083        // reason it carries is preferred in the message below over a plain
1084        // shutdown park racing it at the same boundary.
1085        if !self.pause.parked() && !self.interrupt.parked() {
1086            return Ok(false);
1087        }
1088        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1089            Some(reason) => format!(
1090                "parked after `{}` ({reason}) — resume to carry on from here",
1091                self.state.status.as_str()
1092            ),
1093            None => format!(
1094                "parked after `{}` — resume to carry on from here",
1095                self.state.status.as_str()
1096            ),
1097        };
1098        self.state.event("park", why);
1099        self.state.parked = true;
1100        self.state.save()?;
1101        Ok(true)
1102    }
1103
1104    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1105    pub fn on_pause(&mut self, pause: Pause) {
1106        self.pause = pause;
1107    }
1108
1109    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1110    /// scheduler asking this one run - and no other - to park so a task
1111    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1112    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1113    /// handle.
1114    pub fn watch_interrupt(&mut self, pause: Pause) {
1115        self.interrupt = pause;
1116    }
1117
1118    /// Abandon this run's own open questions, once `status` has actually
1119    /// settled rather than merely paused.
1120    ///
1121    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1122    /// either back up with the candidates, the review round and the seat
1123    /// sessions already on disk, so a question an implementer asked mid-round
1124    /// may still get a real answer read by a real resume. Only the statuses
1125    /// `resumable` excludes are actually final: the run merged, it reached
1126    /// `Ready` with nothing left to do, it failed outright with no
1127    /// established point to continue from, or every candidate agreed, with
1128    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1129    /// every one of those the seat that asked is gone for good, exactly like
1130    /// the run being deleted under `magi run rm` - so the same cleanup
1131    /// applies, worded for what actually happened instead of "the run was
1132    /// deleted".
1133    ///
1134    /// Best-effort and silent on success: called from every place `status`
1135    /// can land on one of those three, including ones a resumed run revisits,
1136    /// so it must cost nothing when there was nothing open to begin with.
1137    fn settle_questions(&mut self) {
1138        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1139            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1140        }
1141    }
1142
1143    /// The tail of the graph after a trustworthy tally: fold losers, review,
1144    /// gate, merge, and persist.
1145    async fn finish_after_tally(&mut self) -> Result<()> {
1146        self.fold_losers().await?;
1147        // Before review starts, and again right before the gate: a run's
1148        // review rounds can themselves take long enough for the base to move
1149        // a second time, and the gate is the one node whose "green" gets
1150        // acted on.
1151        self.sync_to_base().await?;
1152        if self.state.status == RunStatus::AlreadyInBase {
1153            return Ok(());
1154        }
1155        self.review_loop().await?;
1156        self.sync_to_base().await?;
1157        if self.state.status == RunStatus::AlreadyInBase {
1158            return Ok(());
1159        }
1160        self.gate().await?;
1161        self.merge().await?;
1162        self.state.save()?;
1163        Ok(())
1164    }
1165
1166    // ---------------------------------------------------------------- prep
1167
1168    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1169    /// run with the step and its output in the event; it is never swallowed.
1170    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1171    /// resume starts over instead of finding candidates and missing seats.
1172    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1173        let result = match ensure_setup_config(&mut self.state, repo).await {
1174            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1175            Err(e) => Err(e),
1176        };
1177        if let Err(e) = result {
1178            git::worktree_remove(repo, wt).await.ok();
1179            if in_prep {
1180                self.rollback_prep(repo).await;
1181            }
1182            self.state.status = RunStatus::Blocked;
1183            self.state.event(
1184                "setup",
1185                format!("worktree setup failed in {}: {e:#}", wt.display()),
1186            );
1187            self.state.save()?;
1188            return Err(e);
1189        }
1190        Ok(())
1191    }
1192
1193    /// Undo everything `prep` created, so a resume re-runs it whole.
1194    async fn rollback_prep(&mut self, repo: &Path) {
1195        let root = self.state.worktree_root();
1196        for c in std::mem::take(&mut self.state.candidates) {
1197            git::worktree_remove(repo, &c.worktree).await.ok();
1198            git::branch_delete(repo, &c.branch).await.ok();
1199        }
1200        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1201        for j in 1..=seats {
1202            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1203                .await
1204                .ok();
1205        }
1206        for k in 1..=self.state.config.graph.advisors {
1207            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1208                .await
1209                .ok();
1210        }
1211    }
1212
1213    async fn prep(&mut self) -> Result<()> {
1214        if !self.state.candidates.is_empty() {
1215            return Ok(());
1216        }
1217        self.state.status = RunStatus::Prep;
1218        let repo = self.state.repo.clone();
1219        let base = self.state.base_commit.clone();
1220        let plan = refs::plan(&repo, &self.state.seeds).await?;
1221        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1222        let root = self.state.worktree_root();
1223        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1224
1225        // The hook is the write-time half of the blindness contract; the
1226        // presentation filter in `blind` is the half that cannot be bypassed.
1227        let hooks_dir = self.state.dir().join("hooks");
1228        if self.state.config.blind.commit_msg_hook {
1229            std::fs::create_dir_all(&hooks_dir)
1230                .with_context(|| format!("create {}", hooks_dir.display()))?;
1231            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1232            let path = hooks_dir.join("commit-msg");
1233            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1234            make_executable(&path)?;
1235            // Ref-counted rather than a plain idempotent set: with more than
1236            // one run able to be in flight in the same repository at once
1237            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1238            // true?" check cannot tell "another run of mine still needs
1239            // this" from "nobody does", and the run that happens to finish
1240            // first would disable the hook out from under a sibling still
1241            // relying on it.
1242            git::acquire_worktree_config(&repo).await?;
1243            self.state.enabled_worktree_config = true;
1244        }
1245
1246        // `[worktree] setup` hides its products per worktree, which needs the
1247        // same per-worktree config the hook does. Held until fold, like it.
1248        ensure_setup_config(&mut self.state, &repo).await?;
1249
1250        for (index, (spec, label)) in self
1251            .roles
1252            .implementers
1253            .clone()
1254            .into_iter()
1255            .zip(labels)
1256            .enumerate()
1257        {
1258            let branch = self.state.branch_for(label);
1259            let worktree = root.join(format!("cand-{label}"));
1260            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1261            if self.state.config.blind.commit_msg_hook {
1262                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1263            }
1264            git::local_exclude(&worktree, "/.magi/").await?;
1265            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1266                // Nothing of this prep may survive: a resume must start over,
1267                // not find half the candidates and skip the rest.
1268                git::worktree_remove(&repo, &worktree).await.ok();
1269                git::branch_delete(&repo, &branch).await.ok();
1270                self.rollback_prep(&repo).await;
1271                self.state.status = RunStatus::Blocked;
1272                self.state
1273                    .event("prep", format!("worktree setup failed: {e:#}"));
1274                self.state.save()?;
1275                return Err(e);
1276            }
1277            for pick in &plan.picks {
1278                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1279                    self.state.status = RunStatus::Blocked;
1280                    self.state
1281                        .event("prep", format!("cannot apply referenced commit: {e}"));
1282                    self.state.save()?;
1283                    return Err(e);
1284                }
1285            }
1286            self.state.candidates.push(Candidate {
1287                index,
1288                label,
1289                agent: spec.id.clone(),
1290                branch,
1291                worktree,
1292                summary: String::new(),
1293                stat: String::new(),
1294                files: 0,
1295                commits: 0,
1296                empty: false,
1297                failed: None,
1298                verified_noop: None,
1299                duration_ms: 0,
1300                folded: false,
1301            });
1302        }
1303
1304        for j in 1..=self.roles.judges.len() {
1305            let wt = root.join(format!("judge-{j}"));
1306            if !wt.exists() {
1307                git::worktree_add_detached(&repo, &wt, &base).await?;
1308                self.setup_seat_worktree(&repo, &wt, true).await?;
1309            }
1310        }
1311
1312        // Disposable, detached checkouts for the design-deliberation stage's
1313        // advisor seats — the same shape as the judges' above, at the same
1314        // base commit, since advisors also only ever read. Sized off the
1315        // configured count directly rather than a resolved roster: unlike
1316        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1317        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1318        // `prep` has no `ResolvedRoles` field to read a count from here.
1319        if self.state.config.graph.advise {
1320            for k in 1..=self.state.config.graph.advisors {
1321                let wt = root.join(format!("advisor-{k}"));
1322                if !wt.exists() {
1323                    git::worktree_add_detached(&repo, &wt, &base).await?;
1324                    self.setup_seat_worktree(&repo, &wt, true).await?;
1325                }
1326            }
1327        }
1328
1329        // A judge cannot tell it is looking at its own patch — the seats keep
1330        // separate conversations — but a panel that shares agents with the
1331        // field is less independent than it looks, and that is worth saying out
1332        // loud once per run rather than leaving it in the config.
1333        let authors: Vec<&str> = self
1334            .roles
1335            .implementers
1336            .iter()
1337            .map(|a| a.id.as_str())
1338            .collect();
1339        let overlap: Vec<String> = self
1340            .roles
1341            .judges
1342            .iter()
1343            .enumerate()
1344            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1345            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1346            .collect();
1347        if !overlap.is_empty() {
1348            let note = format!(
1349                "{} also authored a candidate; blind, but the panel is less \
1350                 independent than {} distinct agents would be",
1351                overlap.join(", "),
1352                self.roles.judges.len()
1353            );
1354            self.state.event("prep", note);
1355        }
1356
1357        self.state.event(
1358            "prep",
1359            format!(
1360                "{} candidates, {} judges, base {} ({})",
1361                self.state.candidates.len(),
1362                self.roles.judges.len(),
1363                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1364                self.state.base_branch
1365            ),
1366        );
1367        self.state.status = RunStatus::Implementing;
1368        self.state.save()?;
1369        Ok(())
1370    }
1371
1372    // -------------------------------------------------------------- advise
1373
1374    /// The design-deliberation stage: independent, read-only advisor seats
1375    /// each sketch a design before any implementer touches the repository,
1376    /// and (when at least one produced a usable proposal) a synthesis seat
1377    /// blends them into a brief `implement` carries in every candidate's
1378    /// prompt.
1379    ///
1380    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1381    /// advisors` is the proposal count. Everything here is best-effort and
1382    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1383    /// that cannot reach quota, or a synthesis seat that produced nothing
1384    /// usable all leave `implement` exactly as it was before this stage
1385    /// existed — the task instruction alone — rather than failing the whole
1386    /// competition over an enrichment stage. Every outcome is still recorded
1387    /// as an event, so a run that got nothing from this stage says why.
1388    ///
1389    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1390    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1391    /// resumed run whose stage failed would re-run it, and re-spend the
1392    /// agent calls, on every reentry before `implement`.
1393    ///
1394    /// Also skipped once any candidate shows implementation progress — the
1395    /// exact predicate `implement` itself uses to decide a candidate is no
1396    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1397    /// is not enough: a run created by an older binary that predates this
1398    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1399    /// an already-`Implementing`-or-later run under this build would
1400    /// otherwise walk straight back through `prep` (a no-op once candidates
1401    /// exist) into this node and spawn every advisor seat against worktrees
1402    /// `prep` never recreated — after implementation has already started,
1403    /// which is exactly the invariant this stage exists to guarantee.
1404    async fn advise(&mut self) -> Result<()> {
1405        let implement_untouched = self
1406            .state
1407            .candidates
1408            .iter()
1409            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1410        if !self.state.config.graph.advise || self.state.advise_attempted {
1411            return Ok(());
1412        }
1413        if !implement_untouched {
1414            self.state.event(
1415                "advise",
1416                "skipping the design-deliberation stage: at least one \
1417                 candidate already shows implementation progress, so this \
1418                 run is past the point the stage exists to run before"
1419                    .to_owned(),
1420            );
1421            self.state.advise_attempted = true;
1422            self.state.save()?;
1423            return Ok(());
1424        }
1425        let run_id = self.state.id.clone();
1426        let prompts = self.state.config.prompts.clone();
1427        let instruction = self.state.instruction.clone();
1428        let language = self.state.config.graph.language.clone();
1429        let root = self.state.worktree_root();
1430        let n = self.state.config.graph.advisors;
1431        let where_recorded = self.state.dir().join("run.json");
1432
1433        let seats = match self.state.config.advisors() {
1434            Ok(seats) if !seats.is_empty() => seats,
1435            Ok(_) => {
1436                self.state.event(
1437                    "advise",
1438                    format!(
1439                        "[graph] advisors is 0; skipping the design-deliberation \
1440                         stage and continuing without a synthesis brief (see {})",
1441                        where_recorded.display()
1442                    ),
1443                );
1444                self.state.advise_attempted = true;
1445                self.state.save()?;
1446                return Ok(());
1447            }
1448            Err(e) => {
1449                self.state.event(
1450                    "advise",
1451                    format!(
1452                        "could not resolve advisor seats ({e:#}); continuing \
1453                         without a design-deliberation brief (see {})",
1454                        where_recorded.display()
1455                    ),
1456                );
1457                self.state.advise_attempted = true;
1458                self.state.save()?;
1459                return Ok(());
1460            }
1461        };
1462
1463        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1464        let artifacts = agent::artifacts_dir(&self.state.dir());
1465        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1466
1467        let mut jobs = Vec::new();
1468        for (i, spec) in seats.iter().cloned().enumerate() {
1469            let seat_key = format!("advisor-{}", i + 1);
1470            let seat = self.seat(&seat_key, &spec.id);
1471            jobs.push(SeatJob {
1472                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1473                spec,
1474                seat,
1475                cwd: worktrees[i % worktrees.len()].clone(),
1476                timeout,
1477                allow_write: false,
1478                sessions: false,
1479                artifacts: artifacts.clone(),
1480                stem: seat_key,
1481                handover: None,
1482            });
1483        }
1484
1485        self.state.event(
1486            "advise",
1487            format!(
1488                "{} advisor seat(s) sketching a design in parallel",
1489                jobs.len()
1490            ),
1491        );
1492        let mut quota_losses = Vec::new();
1493        let cache = self.state.config.cache_dir();
1494        let ctx = WaveCtx {
1495            carry_seats: false,
1496            run: &run_id,
1497            node: "advise",
1498            prompts: &prompts,
1499            cache: cache.as_deref(),
1500            round: None,
1501        };
1502        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1503        let results = ask_json_wave::<Proposal>(
1504            jobs,
1505            Arc::clone(&self.sem),
1506            self.state.config.graph.retries,
1507            &advisor_roster,
1508            &ctx,
1509            &mut quota_losses,
1510            &mut self.state,
1511            &|p: &Proposal| p.validate(),
1512        )
1513        .await;
1514        self.state.quota.extend(quota_losses);
1515
1516        let mut records = Vec::with_capacity(results.len());
1517        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1518            let agent_id = seat.agent.clone();
1519            self.state.seats.insert(seat.key.clone(), seat);
1520            match res {
1521                Ok((proposal, out)) => {
1522                    self.state
1523                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1524                    records.push(advise::AdvisorRecord::proposed(
1525                        i + 1,
1526                        agent_id,
1527                        proposal,
1528                        out.duration_ms,
1529                    ));
1530                }
1531                Err(e) => {
1532                    self.state.event(
1533                        "advise",
1534                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1535                    );
1536                    records.push(advise::AdvisorRecord::failed(
1537                        i + 1,
1538                        agent_id,
1539                        e.to_string(),
1540                    ));
1541                }
1542            }
1543        }
1544
1545        let mut advice = advise::Advice {
1546            records,
1547            synthesis: None,
1548        };
1549        if advice.proposals().is_empty() {
1550            self.state.event(
1551                "advise",
1552                "no advisor produced a usable proposal; continuing without a \
1553                 synthesis brief"
1554                    .to_owned(),
1555            );
1556        } else {
1557            match self
1558                .synthesize_brief(
1559                    &advice,
1560                    &instruction,
1561                    &language,
1562                    &worktrees[0],
1563                    &artifacts,
1564                    &run_id,
1565                    &prompts,
1566                    cache.as_deref(),
1567                )
1568                .await
1569            {
1570                Ok(Some(text)) => {
1571                    self.state.event(
1572                        "advise",
1573                        "synthesized a design brief for the implementer".to_owned(),
1574                    );
1575                    advice.synthesis = Some(text);
1576                }
1577                Ok(None) => {
1578                    self.state.event(
1579                        "advise",
1580                        "the synthesis seat produced nothing usable; continuing \
1581                         without a design brief"
1582                            .to_owned(),
1583                    );
1584                }
1585                Err(e) => {
1586                    self.state.event(
1587                        "advise",
1588                        format!("could not synthesize a design brief: {e:#}"),
1589                    );
1590                }
1591            }
1592        }
1593        advise::apply_reflection(&mut advice);
1594
1595        self.state.advice = Some(advice);
1596        self.state.advise_attempted = true;
1597        self.state.save()?;
1598        Ok(())
1599    }
1600
1601    /// The synthesis seat: reads every advisor's proposal and blends them
1602    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1603    /// out of [`Runner::advise`] only for readability — it is not called
1604    /// anywhere else.
1605    ///
1606    /// Picked the same way [`crate::talk`]'s standing conversation and
1607    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1608    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1609    /// order (a claude seat, else the first runnable agent in roster order)
1610    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1611    /// in [`crate::config`] for why a dedicated field exists here at all.
1612    #[allow(clippy::too_many_arguments)]
1613    async fn synthesize_brief(
1614        &mut self,
1615        advice: &advise::Advice,
1616        instruction: &str,
1617        language: &str,
1618        cwd: &Path,
1619        artifacts: &Path,
1620        run_id: &str,
1621        prompts: &Prompts,
1622        cache: Option<&Path>,
1623    ) -> Result<Option<String>> {
1624        let chain = agent::pick_chain(
1625            &self.state.config.agents,
1626            self.state.config.roles.synthesizer.as_ref(),
1627            &agent::installed,
1628            "synthesizer",
1629        )?;
1630        let proposals = advice.proposals();
1631        let mut prompt = prompt::with_overlay(
1632            prompt::synthesize_brief(instruction, &proposals, language),
1633            prompts.overlay("advise"),
1634        );
1635        if cache.is_some() {
1636            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1637            // below — see `prompt::build_cache_note`'s doc for why telling a
1638            // read-only seat to build through the shared cache is exactly how
1639            // a sandbox's write refusal gets misread as a defect.
1640            prompt.push('\n');
1641            prompt.push_str(&prompt::build_cache_note("advise", false));
1642        }
1643        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1644        // Each id is tried once, in order; a quota hit, error or unusable
1645        // answer moves to the next. The seat is single-turn (`sessions:
1646        // false`) and the prompt is the whole context, so a fallback agent
1647        // needs nothing carried over.
1648        let mut last = None;
1649        for (n, spec) in chain.iter().enumerate() {
1650            if n > 0 {
1651                self.state
1652                    .event("advise", format!("synthesis falling back to {}", spec.id));
1653            }
1654            let mut seat = self.seat("advise-synthesis", &spec.id);
1655            let outcome = agent::invoke(
1656                spec,
1657                &mut seat,
1658                &Invocation {
1659                    cwd,
1660                    prompt: &prompt,
1661                    timeout,
1662                    allow_write: false,
1663                    unsandboxed: false,
1664                    sessions: false,
1665                    artifacts,
1666                    stem: &if n == 0 {
1667                        "advise-synthesis".to_owned()
1668                    } else {
1669                        format!("advise-synthesis-{}", spec.id)
1670                    },
1671                    run: run_id,
1672                    node: "advise",
1673                    cache_dir: None,
1674                    attachments: &[],
1675                    writable: &[],
1676                },
1677            )
1678            .await;
1679            if outcome.is_ok() {
1680                self.state.seats.insert(seat.key.clone(), seat);
1681            }
1682            let advance = agent::chain_advances(&outcome);
1683            last = Some(outcome);
1684            if !advance {
1685                break;
1686            }
1687        }
1688        // Exhausted: the last attempt's result is what a single failed seat
1689        // would have produced.
1690        let out = last.expect("a chain holds at least one agent")?;
1691        if !out.usable() {
1692            return Ok(None);
1693        }
1694        let text =
1695            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1696        Ok((!text.trim().is_empty()).then_some(text))
1697    }
1698
1699    // ----------------------------------------------------------- implement
1700
1701    async fn implement(&mut self) -> Result<()> {
1702        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1703        // agent files with `magi task add` name the run that paid for it. The
1704        // prompt overlay is cloned alongside it because the waves borrow it
1705        // while `self` is mutably borrowed by the node's own bookkeeping.
1706        let run_id = self.state.id.clone();
1707        let prompts = self.state.config.prompts.clone();
1708        let todo: Vec<usize> = self
1709            .state
1710            .candidates
1711            .iter()
1712            .enumerate()
1713            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1714            .map(|(i, _)| i)
1715            .collect();
1716        if todo.is_empty() {
1717            return self.after_implement();
1718        }
1719        self.state.status = RunStatus::Implementing;
1720
1721        let language = self.state.config.graph.language.clone();
1722        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1723        let sessions = self.state.config.graph.sessions;
1724        let artifacts = agent::artifacts_dir(&self.state.dir());
1725        // The design-deliberation stage's blended brief, when `advise` found
1726        // one — carried into every implementer's prompt the same way
1727        // regardless of which candidate it is.
1728        let brief = self
1729            .state
1730            .advice
1731            .as_ref()
1732            .and_then(|a| a.synthesis.as_deref())
1733            .map(str::to_owned);
1734        let attachments = self.state.attachments.clone();
1735
1736        let mut jobs = Vec::new();
1737        for &i in &todo {
1738            let (index, label, worktree) = {
1739                let c = &self.state.candidates[i];
1740                (c.index, c.label, c.worktree.clone())
1741            };
1742            let spec = self.roles.implementers[index].clone();
1743            let seat_key = format!("impl-{label}");
1744            let seat = self.seat(&seat_key, &spec.id);
1745            let instruction = seeded_instruction(&self.state);
1746            jobs.push(SeatJob {
1747                spec,
1748                seat,
1749                prompt: prompt::implement(
1750                    &instruction,
1751                    &worktree.to_string_lossy(),
1752                    &language,
1753                    brief.as_deref(),
1754                    &attachments,
1755                ),
1756                cwd: worktree,
1757                timeout,
1758                allow_write: true,
1759                sessions,
1760                artifacts: artifacts.clone(),
1761                stem: format!("impl-{label}"),
1762                handover: None,
1763            });
1764        }
1765
1766        self.state.event(
1767            "implement",
1768            format!("{} candidates in parallel", jobs.len()),
1769        );
1770        // Kept so a seat whose CLI hung up can be asked again from the same
1771        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1772        // can update a seat's own entry once a fallback agent takes it over —
1773        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1774        // whichever agent actually answered, not the one that quota'd out.
1775        let mut sent = jobs.clone();
1776        let cache = self.state.config.cache_dir();
1777        let ctx = WaveCtx {
1778            carry_seats: false,
1779            run: &run_id,
1780            node: "implement",
1781            prompts: &prompts,
1782            cache: cache.as_deref(),
1783            round: None,
1784        };
1785        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1786        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1787            .await;
1788        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1789            .await;
1790        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1791            .await;
1792
1793        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1794            let seat_key = seat.key.clone();
1795            // A quota fallback (`resume_seat_handovers`) may have handed this
1796            // seat to a different agent than the one `prep` recorded on the
1797            // candidate; the stats tables and any later fixer-defaults-to-
1798            // winner's-author lookup must credit whoever actually answered —
1799            // unless every fallback also quota'd out, in which case nobody
1800            // actually answered and crediting the last agent tried would
1801            // erase every earlier agent's own quota loss from the stats
1802            // tables instead of just this one seat's.
1803            let agent = seat.agent.clone();
1804            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1805            self.state.seats.insert(seat.key.clone(), seat);
1806            let label = self.state.candidates[i].label;
1807            let worktree = self.state.candidates[i].worktree.clone();
1808            let base = self.state.base_commit.clone();
1809
1810            let (summary, duration, failed, verified_claim) = match out {
1811                AgentOutcome::Ok(o) => {
1812                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1813                    let failed = (!o.usable()).then(|| {
1814                        if o.timed_out {
1815                            "agent timed out".to_owned()
1816                        } else {
1817                            format!("agent exited with {:?}", o.exit_code)
1818                        }
1819                    });
1820                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1821                    (text, o.duration_ms, failed, verified_claim)
1822                }
1823                // Left un-resumed by `resume_undelivered` (a dirty tree
1824                // already rescues the work, or there was no session left to
1825                // resume into) — reported like the ordinary failure it is,
1826                // never as if `o.text` (the CLI's raw error JSON) were an
1827                // answer.
1828                AgentOutcome::Dropped(o) => {
1829                    let why = o
1830                        .dropped
1831                        .as_ref()
1832                        .map(|d| d.why.as_str())
1833                        .unwrap_or("the CLI ended the stream without delivering its answer");
1834                    (
1835                        String::new(),
1836                        o.duration_ms,
1837                        Some(format!("the CLI dropped the stream ({why})")),
1838                        None,
1839                    )
1840                }
1841                AgentOutcome::Quota(o) => {
1842                    self.state.quota.push(QuotaLoss {
1843                        seat: seat_key,
1844                        node: "implement".to_owned(),
1845                        at: Timestamp::now(),
1846                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1847                    });
1848                    (
1849                        String::new(),
1850                        o.duration_ms,
1851                        Some("rate limited (quota); produced no change".to_owned()),
1852                        None,
1853                    )
1854                }
1855                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1856            };
1857
1858            // Rescue anything the agent edited but never committed: an
1859            // uncommitted candidate would silently be an empty one.
1860            let rescued = match git::rescue_commit(
1861                &worktree,
1862                &format!("magi: candidate {label} (uncommitted work)"),
1863            )
1864            .await
1865            {
1866                Ok(r) => {
1867                    self.state.note_withheld("implement", &r.withheld);
1868                    r.committed
1869                }
1870                Err(_) => false,
1871            };
1872            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1873                .await
1874                .unwrap_or(0);
1875            let patch = git::diff(&worktree, &base, "HEAD")
1876                .await
1877                .unwrap_or_default();
1878            let stat = git::diff_stat(&worktree, &base, "HEAD")
1879                .await
1880                .unwrap_or_default();
1881            let files = git::changed_files(&worktree, &base, "HEAD")
1882                .await
1883                .map(|f| f.len())
1884                .unwrap_or(0);
1885            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1886
1887            let c = &mut self.state.candidates[i];
1888            if !exhausted_the_fallback_chain {
1889                c.agent = agent;
1890            }
1891            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1892            c.stat = stat;
1893            c.files = files;
1894            c.commits = commits;
1895            c.duration_ms = duration;
1896            c.empty = commits == 0 || patch.trim().is_empty();
1897            // An agent that failed but still produced a committed change stays
1898            // in the running: the patch is what gets judged, not the exit code.
1899            c.failed = match failed {
1900                Some(_) if c.empty => failed,
1901                _ => None,
1902            };
1903            // Only an empty candidate can be a verified no-op: a claim next
1904            // to a real patch is not what the marker is for, and `c.failed`
1905            // being `Some` here already implies `verified_claim` was never
1906            // set (see the guard above the match that produced it).
1907            c.verified_noop = if c.empty { verified_claim } else { None };
1908            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1909                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1910                (None, true, Some(_), _) => {
1911                    format!("candidate {label}: no change produced (agent-verified no-op)")
1912                }
1913                (None, true, None, _) => format!("candidate {label}: no change produced"),
1914                (None, false, _, true) => {
1915                    format!(
1916                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1917                    )
1918                }
1919                (None, false, _, false) => {
1920                    format!("candidate {label}: {files} files, {commits} commits")
1921                }
1922            };
1923            self.state.event("implement", note);
1924            self.state.save()?;
1925        }
1926
1927        self.after_implement()
1928    }
1929
1930    /// Ask again, once, for work a CLI did and then failed to hand over.
1931    ///
1932    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1933    /// status with an empty response and a usage report showing output tokens,
1934    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1935    /// seven minutes and 14,267 output tokens that arrived as an empty
1936    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1937    ///
1938    /// Two conditions, and both matter:
1939    ///
1940    /// - **Only when the tree is untouched.** Often the agent has already
1941    ///   written its files and only the closing message was lost; the rescue
1942    ///   commit below picks that up and there is nothing to ask for. Re-asking
1943    ///   then would pay for a second implementation of work already on disk.
1944    /// - **Once.** A CLI that drops one stream can drop the next, and this
1945    ///   node is the most expensive in the graph.
1946    ///
1947    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1948    /// dropped reply still carried its `conversation_id`, so the seat is asked
1949    /// to finish what it was doing rather than sent the whole task again. It
1950    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
1951    /// node's - for the same reason a re-ranked judge does: restating finished
1952    /// work is not the work.
1953    ///
1954    /// Unlike a quota this is worth retrying at all: a rate limit fails the
1955    /// same way until it resets, while an abandoned conversation is still
1956    /// there to be picked up.
1957    async fn resume_undelivered(
1958        &mut self,
1959        results: &mut [(usize, SeatState, AgentOutcome)],
1960        sent: &[SeatJob],
1961        prompts: &Prompts,
1962        run_id: &str,
1963    ) {
1964        for (wi, seat, out) in results.iter_mut() {
1965            let Some(dropped) = (match &*out {
1966                AgentOutcome::Dropped(o) => o.dropped.clone(),
1967                _ => None,
1968            }) else {
1969                continue;
1970            };
1971            let Some(job) = sent.get(*wi) else { continue };
1972            // Already on disk? Then only the closing message was lost.
1973            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1974                self.state.event(
1975                    "implement",
1976                    format!(
1977                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1978                         work is in the tree",
1979                        seat.key, dropped.output_tokens, dropped.why
1980                    ),
1981                );
1982                continue;
1983            }
1984            // The re-ask only makes sense as a resume: `resume_after_drop`
1985            // says nothing about the task, trusting the seat to still hold it.
1986            // Without a session to resume — sessions disabled, or this CLI's
1987            // drop shape happened not to carry a session id — that prompt
1988            // would open a brand-new conversation with no context at all,
1989            // which is worse than leaving this as the ordinary failure it
1990            // already is.
1991            if !has_context(&job.spec, seat, job.sessions) {
1992                self.state.event(
1993                    "implement",
1994                    format!(
1995                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1996                         is no session left to resume",
1997                        seat.key, dropped.output_tokens, dropped.why
1998                    ),
1999                );
2000                continue;
2001            }
2002            self.state.event(
2003                "implement",
2004                format!(
2005                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2006                     conversation",
2007                    seat.key, dropped.output_tokens, dropped.why
2008                ),
2009            );
2010            let mut retry = job.clone();
2011            retry.seat = seat.clone();
2012            retry.prompt = prompt::resume_after_drop(&dropped.why);
2013            retry.timeout = retry_budget(job.timeout, true);
2014            retry.stem = format!("{}-resume", job.stem);
2015            let cache = self.state.config.cache_dir();
2016            let ctx = WaveCtx {
2017                carry_seats: false,
2018                run: run_id,
2019                node: "implement",
2020                prompts,
2021                cache: cache.as_deref(),
2022                round: None,
2023            };
2024            let (resumed_seat, resumed) =
2025                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2026            *seat = resumed_seat;
2027            *out = resumed;
2028        }
2029    }
2030
2031    /// Fall an implement seat through to the next untried agent in the
2032    /// implementer roster when it lost to quota — or, since the handover was
2033    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2034    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2035    /// quota path itself being unchanged — instead of leaving the
2036    /// seat's loss final the moment one agent's account runs dry.
2037    ///
2038    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2039    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2040    /// truncates to the single slot rotation picked, so a solo task whose one
2041    /// implementer hits quota mid-run used to have nothing else to try. This
2042    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2043    /// unrotated roster — which is the only place the *other* candidates in
2044    /// the machine's roster still exist once `implementers` has been cut down
2045    /// to size.
2046    ///
2047    /// Walks forward from just past the seat's own original position in the
2048    /// roster, never wrapping back to the front: a later candidate slot (say
2049    /// `beta`, the roster's second entry) must fall through to the *next*
2050    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2051    /// almost certainly a different candidate's own agent already — and once
2052    /// the roster's tail is exhausted there is nothing left to fall through
2053    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2054    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2055    /// let this retry that id forever. The loop keeps falling through until
2056    /// an attempt lands something other than `Quota` or the roster's tail
2057    /// runs out of untried ids, at which point the seat is left exactly as
2058    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2059    /// `QuotaLoss` recorded, the candidate failed/empty.
2060    ///
2061    /// `sent` is taken mutably and updated with the fallback agent's spec:
2062    /// `resume_unconfirmed_commands`, which runs after this and also reads
2063    /// `sent`, must see whichever agent actually ended up answering the seat
2064    /// — reading the stale, original spec there would check session
2065    /// eligibility against the wrong CLI and could hand a fallback agent's
2066    /// session id to the agent that just lost the seat to quota.
2067    ///
2068    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2069    /// — `self.seat` only reuses state when the agent id is unchanged, so
2070    /// handing it a different id already gets this for free. Reusing the old
2071    /// seat would resume a different CLI's session as if it were a
2072    /// continuation of this one.
2073    ///
2074    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2075    /// a quota loss cuts an agent off mid-turn, so anything already in the
2076    /// tree is unfinished work, not a completed candidate a re-ask would pay
2077    /// for twice. A dirty tree is rescued into a commit first (the same
2078    /// neutral-identity rescue `implement`'s own outcome loop gives every
2079    /// candidate) so the next agent starts clean.
2080    ///
2081    /// The new agent gets the implementer's full prompt and full
2082    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2083    /// it has no session and no context, and is implementing the task from
2084    /// nothing, unlike a resumed drop which is only restating work already
2085    /// done.
2086    ///
2087    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2088    /// `implement` event, never into `self.state.quota` — that is what
2089    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2090    /// go unspent, and a seat that ultimately recovered on its second or
2091    /// third agent is not the stalled panel that check exists to catch. Only
2092    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2093    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2094    /// outcome loop already has — this helper never pushes to it itself.
2095    async fn resume_seat_handovers(
2096        &mut self,
2097        results: &mut [(usize, SeatState, AgentOutcome)],
2098        sent: &mut [SeatJob],
2099        prompts: &Prompts,
2100        run_id: &str,
2101    ) {
2102        let instruction = seeded_instruction(&self.state);
2103        let language = self.state.config.graph.language.clone();
2104        let brief = self
2105            .state
2106            .advice
2107            .as_ref()
2108            .and_then(|a| a.synthesis.as_deref())
2109            .map(str::to_owned);
2110        let attachments = self.state.attachments.clone();
2111        for (wi, seat, out) in results.iter_mut() {
2112            // Who holds the other candidate seats of this wave right now
2113            // (earlier handovers already written back to `sent`).
2114            let others: BTreeSet<String> = sent
2115                .iter()
2116                .enumerate()
2117                .filter(|(j, _)| j != wi)
2118                .map(|(_, j)| j.spec.id.clone())
2119                .collect();
2120            let Some(job) = sent.get_mut(*wi) else {
2121                continue;
2122            };
2123            // Where the seat's own original agent sits in the roster — the
2124            // fallback walk starts just past here, never at the front, so a
2125            // later candidate slot's quota loss does not fall back onto an
2126            // earlier slot's own agent.
2127            let start = self
2128                .roles
2129                .implementer_roster
2130                .iter()
2131                .position(|s| s.id == job.spec.id)
2132                .unwrap_or(0);
2133            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2134            let mut fallback_attempt = 0usize;
2135            let mut prev: Option<FailClass> = None;
2136            while let Some(cur) = FailClass::of(&*out) {
2137                if !should_hand_over(prev.as_ref(), &cur) {
2138                    break;
2139                }
2140                let Some(next) =
2141                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2142                        .cloned()
2143                else {
2144                    break;
2145                };
2146                tried.insert(next.id.clone());
2147                fallback_attempt += 1;
2148
2149                if let Ok(r) = git::rescue_commit(
2150                    &job.cwd,
2151                    &format!(
2152                        "magi: candidate {} (uncommitted work before {} fallback)",
2153                        seat.key,
2154                        if cur == FailClass::Quota {
2155                            "quota"
2156                        } else {
2157                            "handover"
2158                        }
2159                    ),
2160                )
2161                .await
2162                {
2163                    self.state.note_withheld("implement", &r.withheld);
2164                }
2165
2166                record_handover(
2167                    &mut self.state,
2168                    "implement",
2169                    &seat.key,
2170                    &seat.agent,
2171                    &next.id,
2172                    &cur,
2173                    &fail_reason(&*out),
2174                );
2175                prev = Some(cur.clone());
2176
2177                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2178                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2179                // Kept in sync on `sent` itself, not just the local retry: a
2180                // later helper (`resume_unconfirmed_commands`) reads `sent`
2181                // after this one returns and must see whichever agent is now
2182                // occupying the seat, not the one that just quota'd out —
2183                // otherwise it would judge session/continuation eligibility
2184                // by the wrong CLI and could resend a fallback's session id
2185                // to the agent that lost it the seat in the first place.
2186                job.spec = next.clone();
2187                let mut retry = job.clone();
2188                retry.seat = new_seat;
2189                retry.prompt = prompt::implement(
2190                    &instruction,
2191                    &job.cwd.to_string_lossy(),
2192                    &language,
2193                    brief.as_deref(),
2194                    &attachments,
2195                );
2196                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2197                let cache = self.state.config.cache_dir();
2198                let ctx = WaveCtx {
2199                    carry_seats: false,
2200                    run: run_id,
2201                    node: "implement",
2202                    prompts,
2203                    cache: cache.as_deref(),
2204                    round: None,
2205                };
2206                let (fallback_seat, fallback_out) = run_one(
2207                    retry,
2208                    Arc::clone(&self.sem),
2209                    &ctx,
2210                    &mut self.state,
2211                    fallback_attempt,
2212                )
2213                .await;
2214                *seat = fallback_seat;
2215                *out = fallback_out;
2216            }
2217        }
2218    }
2219
2220    /// Ask an implement seat's own CLI to confirm what it started, once, when
2221    /// its reply reported a command whose completion status it never
2222    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2223    /// that does and does not mean.
2224    ///
2225    /// The completion contract this task asks for, extended to `implement`
2226    /// with the same signal `continue_fix_report` reads for the fixer,
2227    /// rather than a keyword search over the reply or a hard requirement on
2228    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2229    /// a candidate's CLI turn ended cleanly while a test run it had started
2230    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2231    /// named in it at all is untouched by this: `commands` is empty, so
2232    /// there is nothing to be unconfirmed.
2233    ///
2234    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2235    /// this is not about recovering edits that might already be on disk, it
2236    /// is about a result the seat itself never vouched for, which resuming
2237    /// asks for regardless of what the tree already holds. Bounded to one
2238    /// attempt for the same reason `resume_undelivered` is — this is the
2239    /// most expensive node in the graph — and a seat that still cannot
2240    /// confirm on that attempt is left as whatever its (possibly still
2241    /// unconfirmed) reply says; this does not invent a new "failed" reason
2242    /// for a candidate that otherwise produced a real, committed change.
2243    async fn resume_unconfirmed_commands(
2244        &mut self,
2245        results: &mut [(usize, SeatState, AgentOutcome)],
2246        sent: &[SeatJob],
2247        prompts: &Prompts,
2248        run_id: &str,
2249    ) {
2250        for (wi, seat, out) in results.iter_mut() {
2251            let AgentOutcome::Ok(o) = &*out else {
2252                continue;
2253            };
2254            if !has_unconfirmed_command(&o.commands) {
2255                continue;
2256            }
2257            let Some(job) = sent.get(*wi) else { continue };
2258            if !has_context(&job.spec, seat, job.sessions) {
2259                self.state.event(
2260                    "implement",
2261                    format!(
2262                        "{}: the reply named a command whose own CLI never confirmed the exit \
2263                         status of, but there is no session left to resume",
2264                        seat.key
2265                    ),
2266                );
2267                continue;
2268            }
2269            self.state.event(
2270                "implement",
2271                format!(
2272                    "{}: the reply named a command whose own CLI never confirmed the exit \
2273                     status of; resuming the conversation",
2274                    seat.key
2275                ),
2276            );
2277            let mut retry = job.clone();
2278            retry.seat = seat.clone();
2279            retry.prompt = prompt::resume_incomplete(
2280                "a command in your last reply had no confirmed exit status",
2281            );
2282            retry.timeout = retry_budget(job.timeout, true);
2283            retry.stem = format!("{}-confirm", job.stem);
2284            let cache = self.state.config.cache_dir();
2285            let ctx = WaveCtx {
2286                carry_seats: false,
2287                run: run_id,
2288                node: "implement",
2289                prompts,
2290                cache: cache.as_deref(),
2291                round: None,
2292            };
2293            let (resumed_seat, resumed) =
2294                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2295            *seat = resumed_seat;
2296            *out = resumed;
2297        }
2298    }
2299
2300    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2301    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2302    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2303    /// that motivated this.
2304    ///
2305    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2306    /// own nudge loop already covers for judge/review/vote seats, and not a
2307    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2308    /// implement seats: here the CLI turn genuinely finished while the node's
2309    /// own work — the fixer's account of what it did — had not. Gated purely
2310    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2311    /// reply, never on any wording in it, so a fixer whose valid, first-try
2312    /// `FixReport` happens to mention having waited on a background test is
2313    /// never resumed — the `Ok(report)` branch at the call site returns
2314    /// before this is ever invoked.
2315    ///
2316    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2317    /// attempt ([`retry_budget`]), nothing attempted once the session is
2318    /// gone, and a quota hit ends the loop immediately rather than retrying a
2319    /// rate limit that fails the same way again.
2320    async fn continue_fix_report(
2321        &mut self,
2322        mut seat: SeatState,
2323        parse_err: String,
2324        job: &SeatJob,
2325        prompts: &Prompts,
2326        run_id: &str,
2327        round: usize,
2328    ) -> (
2329        SeatState,
2330        Option<FixReport>,
2331        Option<String>,
2332        ContinuationRecord,
2333    ) {
2334        let mut last_err = parse_err;
2335        let mut cumulative_wait_ms = 0u64;
2336        let mut attempts = 0usize;
2337        loop {
2338            if !has_context(&job.spec, &seat, job.sessions) {
2339                self.state.event(
2340                    "fix",
2341                    format!(
2342                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2343                         session left to resume into"
2344                    ),
2345                );
2346                let outcome = if attempts == 0 {
2347                    ContinuationOutcome::NoSession
2348                } else {
2349                    ContinuationOutcome::Exhausted
2350                };
2351                return (
2352                    seat,
2353                    None,
2354                    Some(format!("unparsable fix report: {last_err}")),
2355                    ContinuationRecord {
2356                        attempts,
2357                        cumulative_wait_ms,
2358                        outcome,
2359                    },
2360                );
2361            }
2362            if attempts >= MAX_FIX_CONTINUATIONS {
2363                self.state.event(
2364                    "fix",
2365                    format!(
2366                        "round {round}: fixer's reply still had no adoption report after \
2367                         {attempts} continuation(s) ({last_err}); giving up"
2368                    ),
2369                );
2370                return (
2371                    seat,
2372                    None,
2373                    Some(format!(
2374                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2375                    )),
2376                    ContinuationRecord {
2377                        attempts,
2378                        cumulative_wait_ms,
2379                        outcome: ContinuationOutcome::Exhausted,
2380                    },
2381                );
2382            }
2383            attempts += 1;
2384            self.state.event(
2385                "fix",
2386                format!(
2387                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2388                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2389                ),
2390            );
2391            let mut retry = job.clone();
2392            retry.seat = seat.clone();
2393            retry.prompt = prompt::resume_incomplete(&last_err);
2394            retry.timeout = retry_budget(job.timeout, true);
2395            retry.stem = format!("{}-continue{attempts}", job.stem);
2396            let cache = self.state.config.cache_dir();
2397            let ctx = WaveCtx {
2398                carry_seats: false,
2399                run: run_id,
2400                node: "fix",
2401                prompts,
2402                cache: cache.as_deref(),
2403                round: Some(round),
2404            };
2405            let (resumed_seat, resumed_out) = run_one(
2406                retry,
2407                Arc::clone(&self.sem),
2408                &ctx,
2409                &mut self.state,
2410                attempts,
2411            )
2412            .await;
2413            seat = resumed_seat;
2414            match resumed_out {
2415                AgentOutcome::Ok(o) => {
2416                    cumulative_wait_ms += o.duration_ms;
2417                    match verdict::extract_json::<FixReport>(&o.text) {
2418                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2419                            self.state.event(
2420                                "fix",
2421                                format!(
2422                                    "round {round}: fixer's adoption report recovered after \
2423                                     {attempts} continuation(s)"
2424                                ),
2425                            );
2426                            return (
2427                                seat,
2428                                Some(report),
2429                                None,
2430                                ContinuationRecord {
2431                                    attempts,
2432                                    cumulative_wait_ms,
2433                                    outcome: ContinuationOutcome::Resumed,
2434                                },
2435                            );
2436                        }
2437                        // The report parsed, but this same reply's own
2438                        // CommandEvidence — the identical record `state.jobs`
2439                        // renders — names a command whose CLI never
2440                        // confirmed an exit status. Read together, that is
2441                        // not a resolved answer: keep nudging rather than
2442                        // accept a report standing next to a command the
2443                        // seat's own CLI cannot vouch for.
2444                        Ok(_) => {
2445                            last_err = "the reply parsed, but it reported a command whose own CLI \
2446                                 never confirmed an exit status"
2447                                .to_owned();
2448                        }
2449                        Err(e) => last_err = e.to_string(),
2450                    }
2451                }
2452                AgentOutcome::Quota(o) => {
2453                    cumulative_wait_ms += o.duration_ms;
2454                    self.state.quota.push(QuotaLoss {
2455                        seat: seat.key.clone(),
2456                        node: "fix".to_owned(),
2457                        at: Timestamp::now(),
2458                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2459                    });
2460                    self.state.event(
2461                        "fix",
2462                        format!(
2463                            "round {round}: continuation rate limited (quota); not retrying now"
2464                        ),
2465                    );
2466                    return (
2467                        seat,
2468                        None,
2469                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2470                        ContinuationRecord {
2471                            attempts,
2472                            cumulative_wait_ms,
2473                            outcome: ContinuationOutcome::QuotaLost,
2474                        },
2475                    );
2476                }
2477                AgentOutcome::Dropped(o) => {
2478                    cumulative_wait_ms += o.duration_ms;
2479                    let why = o
2480                        .dropped
2481                        .as_ref()
2482                        .map(|d| d.why.as_str())
2483                        .unwrap_or("the CLI ended the stream without delivering its answer");
2484                    last_err = format!("the CLI dropped the stream ({why})");
2485                }
2486                AgentOutcome::Failed(e) => last_err = e,
2487            }
2488        }
2489    }
2490
2491    fn after_implement(&mut self) -> Result<()> {
2492        // Scan every candidate patch once the set is complete.
2493        if self.state.leaks.is_empty() {
2494            let cfg = self.state.config.blind.clone();
2495            let mut leaks = Vec::new();
2496            for c in &self.state.candidates {
2497                let Some(patch) =
2498                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2499                else {
2500                    continue;
2501                };
2502                leaks.extend(blind::scan(
2503                    &format!("candidate {} patch", c.label),
2504                    &patch,
2505                    &cfg.vendor_tokens,
2506                ));
2507            }
2508            if !leaks.is_empty() {
2509                let summary = leaks
2510                    .iter()
2511                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2512                    .collect::<Vec<_>>()
2513                    .join(", ");
2514                match cfg.on_leak {
2515                    LeakPolicy::Fail => {
2516                        self.state.status = RunStatus::Failed;
2517                        self.state
2518                            .event("blind", format!("vendor text in a patch: {summary}"));
2519                        self.state.leaks = leaks;
2520                        self.state.save()?;
2521                        self.settle_questions();
2522                        bail!(
2523                            "blind.on_leak = \"fail\" and vendor text reached a \
2524                             judged patch: {summary}"
2525                        );
2526                    }
2527                    LeakPolicy::Redact => self.state.event(
2528                        "blind",
2529                        format!("redacting vendor text for judging: {summary}"),
2530                    ),
2531                    LeakPolicy::Warn => self.state.event(
2532                        "blind",
2533                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2534                    ),
2535                }
2536                self.state.leaks = leaks;
2537            }
2538        }
2539
2540        if self.state.viable().is_empty() {
2541            if self.state.all_candidates_verified_noop() {
2542                // Every candidate agreed, with evidence the adoption guard
2543                // accepted, that nothing belongs in this worktree. That is
2544                // not the same fact as a candidate that simply failed to
2545                // write anything, and settling it as an ordinary `Failed`
2546                // (see `SCHEMA`'s doc for schema 10) is what let two of
2547                // task 391f's attempts burn a retry each re-discovering the
2548                // same already-landed fix. Terminal either way, so `judge`
2549                // must never run over an empty candidate set — unlike the
2550                // `Failed` branch below this returns `Ok`, not an error:
2551                // nothing here failed.
2552                self.state.status = RunStatus::VerifiedNoop;
2553                self.state.save()?;
2554                self.settle_questions();
2555                return Ok(());
2556            }
2557            self.state.status = RunStatus::Failed;
2558            self.state.save()?;
2559            self.settle_questions();
2560            bail!("no candidate produced a change; nothing to judge");
2561        }
2562        self.state.status = RunStatus::Judging;
2563        self.state.save()?;
2564        Ok(())
2565    }
2566
2567    // --------------------------------------------------------------- judge
2568
2569    async fn judge(&mut self) -> Result<()> {
2570        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2571        // agent files with `magi task add` name the run that paid for it. The
2572        // prompt overlay is cloned alongside it because the waves borrow it
2573        // while `self` is mutably borrowed by the node's own bookkeeping.
2574        let run_id = self.state.id.clone();
2575        let prompts = self.state.config.prompts.clone();
2576        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2577            return Ok(());
2578        }
2579        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2580        if viable.len() == 1 {
2581            // Recorded so this is a one-time event: `judgements` stays empty
2582            // either way, which without this flag is indistinguishable from
2583            // "not yet judged" on the next reentry — and status is left
2584            // untouched, so a later node's conclusion (e.g. `Blocked` after
2585            // the review budget ran out) survives a resume instead of being
2586            // clobbered back to `Judging` by this node running again.
2587            self.state.judge_skipped = true;
2588            self.state.event(
2589                "judge",
2590                format!(
2591                    "only candidate {} produced a change; judging skipped",
2592                    viable[0].label
2593                ),
2594            );
2595            self.state.save()?;
2596            return Ok(());
2597        }
2598        self.state.status = RunStatus::Judging;
2599
2600        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2601        let language = self.state.config.graph.language.clone();
2602        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2603        let sessions = self.state.config.graph.sessions;
2604        let artifacts = agent::artifacts_dir(&self.state.dir());
2605        let root = self.state.worktree_root();
2606        let base_short = short(&self.state.base_commit);
2607
2608        let mut jobs = Vec::new();
2609        let mut orders = Vec::new();
2610        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2611            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2612            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2613            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2614            let seat_key = format!("judge-{}", j + 1);
2615            let seat = self.seat(&seat_key, &spec.id);
2616            jobs.push(SeatJob {
2617                prompt: prompt::judge(
2618                    &self.state.instruction,
2619                    &views,
2620                    self.roles.judges.len(),
2621                    &base_short,
2622                    &language,
2623                ),
2624                spec,
2625                seat,
2626                cwd: root.join(format!("judge-{}", j + 1)),
2627                timeout,
2628                allow_write: false,
2629                sessions,
2630                artifacts: artifacts.clone(),
2631                stem: format!("judge-{}", j + 1),
2632                handover: None,
2633            });
2634        }
2635
2636        self.state.event(
2637            "judge",
2638            format!(
2639                "{} judges ranking {} candidates blind",
2640                jobs.len(),
2641                viable.len()
2642            ),
2643        );
2644        let labels_for_check = labels.clone();
2645        let mut quota_losses = Vec::new();
2646        let cache = self.state.config.cache_dir();
2647        let ctx = WaveCtx {
2648            carry_seats: false,
2649            run: &run_id,
2650            node: "judge",
2651            prompts: &prompts,
2652            cache: cache.as_deref(),
2653            round: None,
2654        };
2655        let results = ask_json_wave::<Ranking>(
2656            jobs,
2657            Arc::clone(&self.sem),
2658            self.state.config.graph.retries,
2659            &self.roles.judge_roster,
2660            &ctx,
2661            &mut quota_losses,
2662            &mut self.state,
2663            &move |r: &Ranking| r.validate(&labels_for_check),
2664        )
2665        .await;
2666        self.state.quota.extend(quota_losses);
2667
2668        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2669            let agent_id = seat.agent.clone();
2670            self.state.seats.insert(seat.key.clone(), seat);
2671            let mut record = Judgement {
2672                judge: j + 1,
2673                seat: format!("judge-{}", j + 1),
2674                agent: agent_id,
2675                ranking: Vec::new(),
2676                reasons: BTreeMap::new(),
2677                confidence: None,
2678                order: orders[j].clone(),
2679                failed: None,
2680                duration_ms: 0,
2681            };
2682            match res {
2683                Ok((ranking, out)) => {
2684                    record.ranking = ranking.normalized();
2685                    record.reasons = ranking.reasons;
2686                    record.confidence = ranking.confidence;
2687                    record.duration_ms = out.duration_ms;
2688                    self.state.event(
2689                        "judge",
2690                        format!(
2691                            "judge {} ranked {}",
2692                            j + 1,
2693                            record.ranking.iter().collect::<String>()
2694                        ),
2695                    );
2696                }
2697                Err(e) => {
2698                    record.failed = Some(e.to_string());
2699                    self.state
2700                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2701                }
2702            }
2703            self.state.judgements.push(record);
2704            self.state.save()?;
2705        }
2706        Ok(())
2707    }
2708
2709    // ---------------------------------------------------------- deliberate
2710
2711    async fn deliberate(&mut self) -> Result<()> {
2712        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2713        // agent files with `magi task add` name the run that paid for it. The
2714        // prompt overlay is cloned alongside it because the waves borrow it
2715        // while `self` is mutably borrowed by the node's own bookkeeping.
2716        let run_id = self.state.id.clone();
2717        let prompts = self.state.config.prompts.clone();
2718        if !self.state.deliberation.is_empty() {
2719            return Ok(());
2720        }
2721        let tops: Vec<char> = self
2722            .state
2723            .judgements
2724            .iter()
2725            .filter_map(|j| j.ranking.first().copied())
2726            .collect();
2727        let rounds = self.state.config.graph.deliberate_rounds;
2728        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2729            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2730                self.state.event(
2731                    "deliberate",
2732                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2733                );
2734            }
2735            self.state.status = RunStatus::Voting;
2736            self.state.save()?;
2737            return Ok(());
2738        }
2739
2740        self.state.status = RunStatus::Deliberating;
2741        self.state.event(
2742            "deliberate",
2743            format!(
2744                "split: first choices were {} — opening {rounds} round(s)",
2745                tops.iter().collect::<String>()
2746            ),
2747        );
2748
2749        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2750        let language = self.state.config.graph.language.clone();
2751        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2752        let sessions = self.state.config.graph.sessions;
2753        let artifacts = agent::artifacts_dir(&self.state.dir());
2754        let root = self.state.worktree_root();
2755        let base_short = short(&self.state.base_commit);
2756
2757        // Judges argue in sequence so that a turn can answer the one before it;
2758        // that is the difference between deliberation and three parallel
2759        // monologues.
2760        for round in 1..=rounds {
2761            let mut turns: Vec<DeliberationTurn> = Vec::new();
2762            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2763                if self.state.judgements[j].failed.is_some() {
2764                    continue;
2765                }
2766                let seat_key = format!("judge-{}", j + 1);
2767                let spec = self.occupant(&seat_key, spec);
2768                let mut seat = self.seat(&seat_key, &spec.id);
2769                let transcript = self.transcript(&turns, j);
2770                let build = |context: Option<&str>| {
2771                    prompt::deliberate(
2772                        &self.state.instruction,
2773                        context,
2774                        &transcript,
2775                        round,
2776                        rounds,
2777                        &language,
2778                    )
2779                };
2780                let block = self.candidate_block(&viable, &base_short);
2781                let full = build(Some(&block));
2782                let text = if has_context(&spec, &seat, sessions) {
2783                    build(None)
2784                } else {
2785                    full.clone()
2786                };
2787                let job = SeatJob {
2788                    spec,
2789                    seat: seat.clone(),
2790                    prompt: text,
2791                    cwd: root.join(format!("judge-{}", j + 1)),
2792                    timeout,
2793                    allow_write: false,
2794                    sessions,
2795                    artifacts: artifacts.clone(),
2796                    stem: format!("delib-{round}-judge-{}", j + 1),
2797                    handover: Some(full),
2798                };
2799                let cache = self.state.config.cache_dir();
2800                let ctx = WaveCtx {
2801                    carry_seats: false,
2802                    run: &run_id,
2803                    node: "deliberate",
2804                    prompts: &prompts,
2805                    cache: cache.as_deref(),
2806                    round: None,
2807                };
2808                // A turn is never nudged (`retries` 0): a failed seat is
2809                // handed to the next roster agent, which gets the full
2810                // context. An empty answer is a turn, not a failure.
2811                let mut losses = Vec::new();
2812                let mut results = ask_wave_with::<String>(
2813                    vec![job],
2814                    Arc::clone(&self.sem),
2815                    0,
2816                    &self.roles.judge_roster,
2817                    &ctx,
2818                    &mut losses,
2819                    &mut self.state,
2820                    &|text: &str| {
2821                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2822                    },
2823                )
2824                .await;
2825                self.state.quota.extend(losses);
2826                let (updated, res, _) = results.pop().expect("one job in, one result out");
2827                seat = updated;
2828                let agent_id = seat.agent.clone();
2829                self.state.seats.insert(seat.key.clone(), seat);
2830                let body = match res {
2831                    Ok((body, _)) => body,
2832                    // Skip the seat; a CLI's raw error JSON is never read as
2833                    // this judge's position.
2834                    Err(e) => {
2835                        self.state
2836                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2837                        continue;
2838                    }
2839                };
2840                let tentative = verdict::extract_json::<Position>(&body)
2841                    .ok()
2842                    .and_then(|p| p.tentative)
2843                    .and_then(|s| s.trim().chars().next())
2844                    .map(|c| c.to_ascii_uppercase());
2845                self.state.event(
2846                    "deliberate",
2847                    format!(
2848                        "round {round}: judge {} now favours {}",
2849                        j + 1,
2850                        tentative.map_or("—".to_owned(), |c| c.to_string())
2851                    ),
2852                );
2853                turns.push(DeliberationTurn {
2854                    judge: j + 1,
2855                    agent: agent_id,
2856                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2857                    tentative,
2858                });
2859            }
2860            self.state
2861                .deliberation
2862                .push(DeliberationRound { round, turns });
2863            self.state.save()?;
2864        }
2865
2866        self.state.status = RunStatus::Voting;
2867        self.state.save()?;
2868        Ok(())
2869    }
2870
2871    // ---------------------------------------------------------------- vote
2872
2873    async fn vote(&mut self) -> Result<()> {
2874        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2875        // agent files with `magi task add` name the run that paid for it. The
2876        // prompt overlay is cloned alongside it because the waves borrow it
2877        // while `self` is mutably borrowed by the node's own bookkeeping.
2878        let run_id = self.state.id.clone();
2879        let prompts = self.state.config.prompts.clone();
2880        if !self.state.votes.is_empty() {
2881            return Ok(());
2882        }
2883        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2884        if viable.len() == 1 {
2885            return Ok(());
2886        }
2887        self.state.status = RunStatus::Voting;
2888
2889        let language = self.state.config.graph.language.clone();
2890        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2891        let sessions = self.state.config.graph.sessions;
2892        let artifacts = agent::artifacts_dir(&self.state.dir());
2893        let root = self.state.worktree_root();
2894        let base_short = short(&self.state.base_commit);
2895        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2896
2897        let mut jobs = Vec::new();
2898        let mut seats_at = Vec::new();
2899        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2900            if self
2901                .state
2902                .judgements
2903                .get(j)
2904                .is_some_and(|r| r.failed.is_some())
2905            {
2906                continue;
2907            }
2908            let seat_key = format!("judge-{}", j + 1);
2909            let spec = self.occupant(&seat_key, spec);
2910            let seat = self.seat(&seat_key, &spec.id);
2911            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2912            let text = if has_context(&spec, &seat, sessions) {
2913                prompt::final_vote(&viable, &language)
2914            } else {
2915                full.clone()
2916            };
2917            jobs.push(SeatJob {
2918                spec,
2919                seat,
2920                prompt: text,
2921                cwd: root.join(format!("judge-{}", j + 1)),
2922                timeout,
2923                allow_write: false,
2924                sessions,
2925                artifacts: artifacts.clone(),
2926                stem: format!("vote-judge-{}", j + 1),
2927                handover: Some(full),
2928            });
2929            seats_at.push(j);
2930        }
2931
2932        self.state.event(
2933            "vote",
2934            format!(
2935                "collecting {} final votes one by one, privately",
2936                jobs.len()
2937            ),
2938        );
2939        let allowed = viable.clone();
2940        let mut quota_losses = Vec::new();
2941        let cache = self.state.config.cache_dir();
2942        let ctx = WaveCtx {
2943            carry_seats: false,
2944            run: &run_id,
2945            node: "vote",
2946            prompts: &prompts,
2947            cache: cache.as_deref(),
2948            round: None,
2949        };
2950        let results = ask_json_wave::<FinalVote>(
2951            jobs,
2952            Arc::clone(&self.sem),
2953            self.state.config.graph.retries,
2954            &self.roles.judge_roster,
2955            &ctx,
2956            &mut quota_losses,
2957            &mut self.state,
2958            &move |v: &FinalVote| match v.label() {
2959                Some(c) if allowed.contains(&c) => Ok(()),
2960                other => bail!("vote {other:?} is not one of {allowed:?}"),
2961            },
2962        )
2963        .await;
2964        self.state.quota.extend(quota_losses);
2965
2966        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2967            let agent_id = seat.agent.clone();
2968            self.state.seats.insert(seat.key.clone(), seat);
2969            let initial = self
2970                .state
2971                .judgements
2972                .get(j)
2973                .and_then(|r| r.ranking.first().copied());
2974            let mut record = VoteRecord {
2975                judge: j + 1,
2976                agent: agent_id,
2977                vote: None,
2978                reason: String::new(),
2979                changed: false,
2980            };
2981            match res {
2982                Ok((v, _)) => {
2983                    record.vote = v.label();
2984                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2985                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2986                    self.state.event(
2987                        "vote",
2988                        format!(
2989                            "judge {} voted {}{}",
2990                            j + 1,
2991                            record.vote.unwrap_or('?'),
2992                            if record.changed { " (changed)" } else { "" }
2993                        ),
2994                    );
2995                }
2996                Err(e) => {
2997                    self.state
2998                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2999                }
3000            }
3001            self.state.votes.push(record);
3002            self.state.save()?;
3003        }
3004        Ok(())
3005    }
3006
3007    // --------------------------------------------------------------- tally
3008
3009    fn tally(&mut self) -> Result<()> {
3010        if self.state.tally.is_some() {
3011            return Ok(());
3012        }
3013        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3014        let tops: Vec<char> = self
3015            .state
3016            .judgements
3017            .iter()
3018            .filter_map(|j| j.ranking.first().copied())
3019            .collect();
3020        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3021
3022        // A judge whose private vote failed still counted once, in the initial
3023        // ranking; using it beats discarding a whole seat.
3024        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3025        let mut cast: Vec<char> = Vec::new();
3026        for (i, j) in self.state.judgements.iter().enumerate() {
3027            let vote = self
3028                .state
3029                .votes
3030                .iter()
3031                .find(|v| v.judge == i + 1)
3032                .and_then(|v| v.vote)
3033                .or_else(|| j.ranking.first().copied());
3034            if let Some(v) = vote {
3035                *first_choice.entry(v).or_insert(0) += 1;
3036                cast.push(v);
3037            }
3038        }
3039
3040        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3041        for j in &self.state.judgements {
3042            let n = j.ranking.len();
3043            for (pos, label) in j.ranking.iter().enumerate() {
3044                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3045            }
3046        }
3047
3048        let best = first_choice.values().copied().max().unwrap_or(0);
3049        let mut leaders: Vec<char> = first_choice
3050            .iter()
3051            .filter(|(_, v)| **v == best)
3052            .map(|(k, _)| *k)
3053            .collect();
3054        let mut tie_break = None;
3055        if leaders.len() > 1 {
3056            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3057            let borda_leaders: Vec<char> = leaders
3058                .iter()
3059                .copied()
3060                .filter(|l| borda[l] == top_borda)
3061                .collect();
3062            tie_break = Some(if borda_leaders.len() == 1 {
3063                format!(
3064                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3065                    leaders.len()
3066                )
3067            } else {
3068                format!(
3069                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3070                    leaders.len()
3071                )
3072            });
3073            leaders = borda_leaders;
3074            leaders.sort_unstable();
3075        }
3076        let winner = *leaders
3077            .first()
3078            .or(viable.first())
3079            .context("no candidate to declare a winner from")?;
3080
3081        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3082        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3083        let deliberated = !self.state.deliberation.is_empty();
3084
3085        // Whose verdict is this? A rate-limited seat is absent even if it
3086        // ranked before the limit hit, so presence is measured against the
3087        // recorded losses, not just "did a ranking ever appear".
3088        let quota_seats: std::collections::BTreeSet<&str> =
3089            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3090        let mut present = 0usize;
3091        for (i, j) in self.state.judgements.iter().enumerate() {
3092            if quota_seats.contains(j.seat.as_str()) {
3093                continue;
3094            }
3095            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3096            let voted = self
3097                .state
3098                .votes
3099                .iter()
3100                .any(|v| v.judge == i + 1 && v.vote.is_some());
3101            if ranked || voted {
3102                present += 1;
3103            }
3104        }
3105        // Strict majority of the configured panel. A bare majority is real
3106        // signal we can act on, while a minority verdict must never stand in
3107        // for a healthy one. A one-candidate run needs no panel at all, and
3108        // `judges` stays `0` rather than the roster size a panel that never
3109        // sat would otherwise be credited with.
3110        let needs_quorum = viable.len() > 1;
3111        let judges_total = if needs_quorum {
3112            self.roles.judges.len()
3113        } else {
3114            0
3115        };
3116        let quorum = if needs_quorum {
3117            judges_total / 2 + 1
3118        } else {
3119            0
3120        };
3121        let met_quorum = !needs_quorum || present >= quorum;
3122        let uncontested = (!needs_quorum).then(|| {
3123            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3124        });
3125
3126        self.state.event(
3127            "tally",
3128            match &uncontested {
3129                Some(reason) => format!("winner {winner} — {reason}"),
3130                None => format!(
3131                    "winner {winner} — votes {} | initial {} | {} changed | \
3132                     {present}/{judges_total} judges{}",
3133                    first_choice
3134                        .iter()
3135                        .map(|(k, v)| format!("{k}:{v}"))
3136                        .collect::<Vec<_>>()
3137                        .join(" "),
3138                    if unanimous_initial {
3139                        "unanimous"
3140                    } else {
3141                        "split"
3142                    },
3143                    changed_votes,
3144                    if met_quorum {
3145                        String::new()
3146                    } else {
3147                        format!(" — below quorum ({quorum} required)")
3148                    },
3149                ),
3150            },
3151        );
3152        if !met_quorum {
3153            self.state.event(
3154                "stall",
3155                format!(
3156                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3157                     the run stops here, resumable"
3158                ),
3159            );
3160        }
3161        self.state.tally = Some(Tally {
3162            first_choice,
3163            borda,
3164            winner,
3165            rankings: tops.len(),
3166            unanimous_initial,
3167            deliberated,
3168            changed_votes,
3169            unanimous_final,
3170            tie_break,
3171            judges: judges_total,
3172            present,
3173            quorum,
3174            met_quorum,
3175            uncontested,
3176        });
3177        self.state.status = if met_quorum {
3178            RunStatus::Reviewing
3179        } else {
3180            RunStatus::Stalled
3181        };
3182        self.state.save()?;
3183        Ok(())
3184    }
3185
3186    // ------------------------------------------------------------- recover
3187
3188    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3189    /// resumed toward completion once the transient cause clears.
3190    ///
3191    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3192    /// it toward the quorum, which is exactly the set of seats whose absence
3193    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3194    /// not depend on which node happened to hit the limit), or an ordinary
3195    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3196    /// seat is never disturbed.
3197    ///
3198    /// A seat that now answers with a usable ranking is "recovered": its
3199    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3200    /// `tally` counts it present again), and its vote re-collected. A seat that
3201    /// still fails keeps its loss and stays absent.
3202    ///
3203    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3204    /// to review/gate/merge), `false` when it is still below quorum (the run
3205    /// stays `Stalled`, still resumable for a later retry).
3206    #[allow(clippy::too_many_lines)]
3207    async fn recover_stall(&mut self) -> Result<bool> {
3208        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3209        // agent files with `magi task add` name the run that paid for it. The
3210        // prompt overlay is cloned alongside it because the waves borrow it
3211        // while `self` is mutably borrowed by the node's own bookkeeping.
3212        let run_id = self.state.id.clone();
3213        let prompts = self.state.config.prompts.clone();
3214        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3215        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3216        // is what keeps a non-quota collapse — or a quota loss recorded at the
3217        // deliberate node — from being a permanent dead-end on `--resume`.
3218        let quota_seats: BTreeSet<&str> =
3219            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3220        let absent: Vec<String> = self
3221            .state
3222            .judgements
3223            .iter()
3224            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3225            .map(|j| j.seat.clone())
3226            .collect();
3227        if absent.is_empty() {
3228            return Ok(false);
3229        }
3230        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3231        if viable.len() <= 1 {
3232            return Ok(false);
3233        }
3234        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3235        let language = self.state.config.graph.language.clone();
3236        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3237        let sessions = self.state.config.graph.sessions;
3238        let artifacts = agent::artifacts_dir(&self.state.dir());
3239        let root = self.state.worktree_root();
3240        let base_short = short(&self.state.base_commit);
3241        let candidates: Vec<Candidate> = viable.clone();
3242
3243        // Map each absent seat key to its 0-based position in `roles.judges`.
3244        let mut positions: Vec<usize> = absent
3245            .iter()
3246            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3247            .collect();
3248        if positions.is_empty() {
3249            return Ok(false);
3250        }
3251        positions.sort_unstable();
3252        positions.dedup();
3253
3254        // Re-rank the lost seats, one blind prompt each.
3255        let mut judge_jobs = Vec::new();
3256        for &j in &positions {
3257            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3258            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3259            let seat_key = format!("judge-{}", j + 1);
3260            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3261            let seat = self.seat(&seat_key, &spec.id);
3262            judge_jobs.push(SeatJob {
3263                spec,
3264                seat,
3265                prompt: prompt::judge(
3266                    &self.state.instruction,
3267                    &views,
3268                    self.roles.judges.len(),
3269                    &base_short,
3270                    &language,
3271                ),
3272                cwd: root.join(seat_key),
3273                timeout,
3274                allow_write: false,
3275                sessions,
3276                artifacts: artifacts.clone(),
3277                stem: format!("judge-{}-recover", j + 1),
3278                handover: None,
3279            });
3280        }
3281
3282        let labels_for_check = labels.clone();
3283        let mut judge_losses = Vec::new();
3284        let retries = self.state.config.graph.retries;
3285        let cache = self.state.config.cache_dir();
3286        let ctx = WaveCtx {
3287            carry_seats: false,
3288            run: &run_id,
3289            node: "judge",
3290            prompts: &prompts,
3291            cache: cache.as_deref(),
3292            round: None,
3293        };
3294        let results = ask_json_wave::<Ranking>(
3295            judge_jobs,
3296            Arc::clone(&self.sem),
3297            retries,
3298            &self.roles.judge_roster,
3299            &ctx,
3300            &mut judge_losses,
3301            &mut self.state,
3302            &move |r: &Ranking| r.validate(&labels_for_check),
3303        )
3304        .await;
3305
3306        // Refresh the judgement of every seat that ranked again.
3307        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3308        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3309            let agent_id = seat.agent.clone();
3310            self.state.seats.insert(seat.key.clone(), seat);
3311            let record = &mut self.state.judgements[j];
3312            match res {
3313                Ok((ranking, out)) => {
3314                    record.agent = agent_id;
3315                    record.ranking = ranking.normalized();
3316                    record.reasons = ranking.reasons;
3317                    record.confidence = ranking.confidence;
3318                    record.failed = None;
3319                    record.duration_ms = out.duration_ms;
3320                    recovered.insert(j);
3321                    self.state.event(
3322                        "recover",
3323                        format!("judge {} ranked again after the limit", j + 1),
3324                    );
3325                }
3326                Err(e) => {
3327                    self.state
3328                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3329                }
3330            }
3331        }
3332
3333        // Re-ask the votes of the seats that recovered a ranking.
3334        let mut vote_jobs = Vec::new();
3335        let mut vote_pos: Vec<usize> = Vec::new();
3336        for &j in &recovered {
3337            let seat_key = format!("judge-{}", j + 1);
3338            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3339            let seat = self.seat(&seat_key, &spec.id);
3340            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3341            let text = if has_context(&spec, &seat, sessions) {
3342                prompt::final_vote(&labels, &language)
3343            } else {
3344                full.clone()
3345            };
3346            vote_jobs.push(SeatJob {
3347                spec,
3348                seat,
3349                prompt: text,
3350                cwd: root.join(seat_key),
3351                timeout,
3352                allow_write: false,
3353                sessions,
3354                artifacts: artifacts.clone(),
3355                stem: format!("vote-judge-{}-recover", j + 1),
3356                handover: Some(full),
3357            });
3358            vote_pos.push(j);
3359        }
3360        let allowed = labels.clone();
3361        let mut vote_losses = Vec::new();
3362        let vote_retries = self.state.config.graph.retries;
3363        let vote_cache = self.state.config.cache_dir();
3364        let ctx = WaveCtx {
3365            carry_seats: false,
3366            run: &run_id,
3367            node: "vote",
3368            prompts: &prompts,
3369            cache: vote_cache.as_deref(),
3370            round: None,
3371        };
3372        let votes = ask_json_wave::<FinalVote>(
3373            vote_jobs,
3374            Arc::clone(&self.sem),
3375            vote_retries,
3376            &self.roles.judge_roster,
3377            &ctx,
3378            &mut vote_losses,
3379            &mut self.state,
3380            &move |v: &FinalVote| match v.label() {
3381                Some(c) if allowed.contains(&c) => Ok(()),
3382                other => bail!("vote {other:?} is not one of {allowed:?}"),
3383            },
3384        )
3385        .await;
3386        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3387            let agent_id = seat.agent.clone();
3388            self.state.seats.insert(seat.key.clone(), seat);
3389            match res {
3390                Ok((v, _)) => {
3391                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3392                        rec.vote = v.label();
3393                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3394                    } else {
3395                        self.state.votes.push(VoteRecord {
3396                            judge: j + 1,
3397                            agent: agent_id,
3398                            vote: v.label(),
3399                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3400                            changed: false,
3401                        });
3402                    }
3403                    self.state.event(
3404                        "recover",
3405                        format!("judge {} voted again after the limit", j + 1),
3406                    );
3407                }
3408                Err(e) => {
3409                    self.state
3410                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3411                }
3412            }
3413        }
3414
3415        // A seat that ranked again is present even if its re-vote failed —
3416        // `tally` falls back to the initial ranking's first choice — so clear
3417        // its quota loss. Seats that still fail keep theirs and stay absent.
3418        let recovered_keys: BTreeSet<String> = recovered
3419            .iter()
3420            .map(|&j| format!("judge-{}", j + 1))
3421            .collect();
3422        self.state
3423            .quota
3424            .retain(|q| !recovered_keys.contains(&q.seat));
3425        // A seat that hit the limit again is a fresh loss, not the old one:
3426        // replace the stale entry so the history stays one-per-seat and the
3427        // daemon can tell this attempt's loss from a previous session's.
3428        for loss in judge_losses.into_iter().chain(vote_losses) {
3429            if recovered_keys.contains(&loss.seat) {
3430                continue;
3431            }
3432            self.state.quota.retain(|q| q.seat != loss.seat);
3433            self.state.quota.push(loss);
3434        }
3435
3436        // Recompute the verdict from the refreshed panel.
3437        self.state.tally = None;
3438        self.tally()?;
3439        Ok(self
3440            .state
3441            .tally
3442            .as_ref()
3443            .map(|t| t.met_quorum)
3444            .unwrap_or(false))
3445    }
3446
3447    // ----------------------------------------------------------------- fold
3448
3449    async fn fold_losers(&mut self) -> Result<()> {
3450        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3451            return Ok(());
3452        };
3453        let repo = self.state.repo.clone();
3454        let mut folded = Vec::new();
3455        for i in 0..self.state.candidates.len() {
3456            let c = &self.state.candidates[i];
3457            if c.label == winner || c.folded {
3458                continue;
3459            }
3460            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3461            git::worktree_remove(&repo, &wt).await.ok();
3462            git::branch_delete(&repo, &branch).await.ok();
3463            self.state.candidates[i].folded = true;
3464            folded.push(label.to_string());
3465        }
3466        // The judges are finished; their checkouts are pure cost from here.
3467        let root = self.state.worktree_root();
3468        for j in 1..=self.roles.judges.len() {
3469            let wt = root.join(format!("judge-{j}"));
3470            if wt.exists() {
3471                git::worktree_remove(&repo, &wt).await.ok();
3472            }
3473        }
3474        // The design-deliberation stage is finished by the time a tally
3475        // exists — same reasoning as the judges above.
3476        if self.state.config.graph.advise {
3477            for k in 1..=self.state.config.graph.advisors {
3478                let wt = root.join(format!("advisor-{k}"));
3479                if wt.exists() {
3480                    git::worktree_remove(&repo, &wt).await.ok();
3481                }
3482            }
3483        }
3484        if !folded.is_empty() {
3485            self.state
3486                .event("fold", format!("folded candidates {}", folded.join(", ")));
3487            self.state.save()?;
3488        }
3489        Ok(())
3490    }
3491
3492    // ------------------------------------------------------------ base sync
3493
3494    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3495    /// anything verifies it.
3496    ///
3497    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3498    /// read whatever is checked out in the winner's worktree. Left alone that
3499    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3500    /// it when the run *branched* - and a run takes long enough that the base
3501    /// has usually moved by the time it gets here. A gate that ran there
3502    /// answers "green on the commit this run started from", not "green on
3503    /// what is about to land", and the difference showed up three times in
3504    /// one day as a green run whose merge would have reverted a file another
3505    /// pull request had already landed.
3506    ///
3507    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3508    /// `land::Step::Rebase` calls, rather than a second implementation of the
3509    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3510    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3511    /// doc warns about ("two answers to a question nobody notices until a
3512    /// diff is wrong").
3513    ///
3514    /// A conflict is not the end of the road: the standing rebase is handed
3515    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3516    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3517    /// land). Once it finishes, review and the gate run as usual on the
3518    /// rebased tree, which is where a breakage the new base caused is caught
3519    /// by the ordinary gate-fix round. magi resolves nothing itself.
3520    ///
3521    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3522    /// `state.base_sync.attempts`, is how many times the base is *rebased
3523    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3524    /// *conflict* was given to a fixer. When the fixer cannot finish the
3525    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3526    /// what was tried (rounds spent, paths still conflicted) and the branch
3527    /// and worktree stay exactly as they were - untouched, for a person to
3528    /// look at - which is also what makes re-entering this function
3529    /// afterwards a no-op instead of a second attempt at the same wall. A
3530    /// push failure ends the same way.
3531    async fn sync_to_base(&mut self) -> Result<()> {
3532        if self.state.status == RunStatus::AlreadyInBase {
3533            return Ok(());
3534        }
3535        let conflicted = self
3536            .state
3537            .base_sync
3538            .as_ref()
3539            .is_some_and(|s| s.conflict.is_some());
3540        let Some(winner) = self.state.winner().cloned() else {
3541            return Ok(());
3542        };
3543
3544        let repo = self.state.repo.clone();
3545        let remote = self.state.config.merge.remote.clone();
3546        let base_branch = self.state.base_branch.clone();
3547        let tracking = format!("{remote}/{base_branch}");
3548
3549        git::fetch(&repo, &remote, &base_branch).await.ok();
3550        // No network, or the remote never had this branch: the run already
3551        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
3552        // otherwise), and one that got this far is not blocked by a fetch
3553        // that fails now. It just has no newer tip to compare against.
3554        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3555            return Ok(());
3556        };
3557
3558        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3559        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3560        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3561
3562        // Before any rebase, and before a recorded conflict is honoured: a
3563        // branch whose change reached the base under other commit ids has
3564        // nothing to rebase and nothing to conflict with, and a run that
3565        // already stopped on that phantom conflict recovers here on resume.
3566        // `behind == 0` with head == tip is a branch the base has since taken
3567        // in whole, whether or not a conflict was ever recorded: the ancestry
3568        // proof must still run (`classify` ignores a head still on the start
3569        // commit).
3570        if (behind > 0 || conflicted || head == tip)
3571            && self
3572                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3573                .await?
3574        {
3575            return Ok(());
3576        }
3577        if conflicted {
3578            return Ok(());
3579        }
3580
3581        if behind == 0 {
3582            // A fixer-finished rebase moves the branch ref before the
3583            // winner's worktree is told (`sync_to_head` below). A run that
3584            // died in between resumes here with `behind == 0` and a tree still
3585            // holding the pre-rebase files, which review and the gate would
3586            // then read. That state is exactly: HEAD moved off the tip the
3587            // rebase started from, yet the tree is still identical to that
3588            // tip. A tree with edits of its own differs from it, so nothing
3589            // is thrown away.
3590            if let Some(from) = self
3591                .state
3592                .rebase_fixes
3593                .iter()
3594                .rev()
3595                .find_map(|r| r.from.clone())
3596                && from != head
3597                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3598                    .await
3599                    .is_ok_and(|o| o.ok())
3600            {
3601                git::sync_to_head(&winner.worktree).await?;
3602            }
3603            // An earlier attempt may have rebased the branch locally and died
3604            // before pushing it (only the fresh-rebase arm below pushes).
3605            // Publish it now, so the plain push at PR time is not refused as
3606            // a non-fast-forward. A run already holding a recorded conflict
3607            // never reaches here; that case is out of scope.
3608            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3609            if let Some(why) = &conflict {
3610                self.state.status = RunStatus::Blocked;
3611                self.state.event("land", why.clone());
3612            }
3613            self.state.base_sync = Some(BaseSync {
3614                tip,
3615                behind: 0,
3616                attempts,
3617                conflict,
3618                already_in: None,
3619            });
3620            self.state.save()?;
3621            return Ok(());
3622        }
3623
3624        if attempts >= BASE_SYNC_ROUNDS {
3625            let why = format!(
3626                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3627                 rebase(s); rebasing again would only race it",
3628                winner.branch
3629            );
3630            self.state.status = RunStatus::Blocked;
3631            self.state.base_sync = Some(BaseSync {
3632                tip,
3633                behind,
3634                attempts,
3635                conflict: Some(why.clone()),
3636                already_in: None,
3637            });
3638            self.state.event("land", why);
3639            self.state.save()?;
3640            return Ok(());
3641        }
3642
3643        self.state.event(
3644            "land",
3645            format!(
3646                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3647                winner.branch
3648            ),
3649        );
3650        self.state.save()?;
3651
3652        // The remote's copy of the branch, read now and only if the fetch
3653        // really succeeded (a stale tracking ref must never pin a lease). It is
3654        // pushed over after a rebase only when it is a commit this branch
3655        // already contains, by ancestry or by patch (an earlier rebase of ours
3656        // that never reached the remote): anything else is somebody else's work.
3657        let branch_tracking = format!("{remote}/{}", winner.branch);
3658        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3659        let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3660            git::rev_parse(&repo, &branch_tracking).await.ok()
3661        } else {
3662            None
3663        };
3664        // A remote tip this branch does not contain is somebody else's work:
3665        // rebasing would leave a local tip that can never be pushed. Stop
3666        // before touching anything and say so.
3667        if let Some(theirs) = &remote_tip
3668            && !git::is_ancestor(&repo, theirs, &head).await
3669            && !crate::reconcile::origin_missing(&repo, &head, theirs)
3670                .await
3671                .is_ok_and(|missing| missing.is_empty())
3672        {
3673            let why = format!(
3674                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3675                 them",
3676                short(theirs),
3677                winner.branch
3678            );
3679            self.state.status = RunStatus::Blocked;
3680            self.state.base_sync = Some(BaseSync {
3681                tip,
3682                behind,
3683                attempts,
3684                conflict: Some(why.clone()),
3685                already_in: None,
3686            });
3687            self.state.event("land", why);
3688            self.state.save()?;
3689            return Ok(());
3690        }
3691
3692        let scratch = self.state.dir().join("base-sync");
3693        let rebased = match crate::rebase::rebase_with_fixer(
3694            &mut self.state,
3695            &scratch,
3696            &winner.branch,
3697            &tracking,
3698        )
3699        .await
3700        {
3701            Ok(crate::rebase::Rebased::Applied) => Ok(None),
3702            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3703            Err(e) => Err(e),
3704        };
3705        let attempts = attempts + 1;
3706        match rebased {
3707            Ok(None) => {
3708                // The branch ref moved, but a worktree that already had it
3709                // checked out (the winner's) was not told; sync its index and
3710                // files before anything reads them.
3711                git::sync_to_head(&winner.worktree).await?;
3712                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3713                let mut conflict = None;
3714                if let Some(pinned) = &remote_tip {
3715                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3716                    match pushed {
3717                        Ok(o) if o.ok() => self.state.event(
3718                            "land",
3719                            format!("pushed rebased {} to {remote}", winner.branch),
3720                        ),
3721                        Ok(o) => {
3722                            conflict = Some(format!(
3723                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
3724                                winner.branch,
3725                                short(pinned),
3726                                o.stderr.chars().take(600).collect::<String>()
3727                            ));
3728                        }
3729                        Err(e) => {
3730                            conflict = Some(format!(
3731                                "rebased {} locally but could not push it: {e:#}",
3732                                winner.branch
3733                            ));
3734                        }
3735                    }
3736                }
3737                if let Some(why) = &conflict {
3738                    self.state.status = RunStatus::Blocked;
3739                    self.state.event("land", why.clone());
3740                }
3741                self.state.base_sync = Some(BaseSync {
3742                    tip: tip.clone(),
3743                    behind: 0,
3744                    attempts,
3745                    conflict,
3746                    already_in: None,
3747                });
3748                self.state
3749                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
3750            }
3751            Ok(Some(conflict)) => {
3752                let why = format!(
3753                    "{} conflicts with {tracking} and did not rebase: {}",
3754                    winner.branch,
3755                    conflict.chars().take(600).collect::<String>()
3756                );
3757                self.state.status = RunStatus::Blocked;
3758                self.state.base_sync = Some(BaseSync {
3759                    tip,
3760                    behind,
3761                    attempts,
3762                    conflict: Some(why.clone()),
3763                    already_in: None,
3764                });
3765                self.state.event("land", why);
3766            }
3767            Err(e) => {
3768                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3769                self.state.status = RunStatus::Blocked;
3770                self.state.base_sync = Some(BaseSync {
3771                    tip,
3772                    behind,
3773                    attempts,
3774                    conflict: Some(why.clone()),
3775                    already_in: None,
3776                });
3777                self.state.event("land", why);
3778            }
3779        }
3780        self.state.save()?;
3781        Ok(())
3782    }
3783
3784    /// Push a branch an earlier attempt rebased locally but never published,
3785    /// pinned to the remote tip read right after a successful fetch. Returns
3786    /// the reason when the run must stop; `None` when there was nothing to do
3787    /// (no remote copy, the same tip, or a remote copy this branch already
3788    /// contains, which the PR-time push fast-forwards) or the push succeeded.
3789    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3790        let repo = self.state.repo.clone();
3791        let remote = self.state.config.merge.remote.clone();
3792        let fetched = git::fetch(&repo, &remote, branch).await;
3793        if !matches!(&fetched, Ok(o) if o.ok()) {
3794            return None;
3795        }
3796        let branch_tracking = format!("{remote}/{branch}");
3797        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3798        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3799            return None;
3800        }
3801        if !crate::reconcile::origin_missing(&repo, head, &theirs)
3802            .await
3803            .is_ok_and(|missing| missing.is_empty())
3804        {
3805            return Some(format!(
3806                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3807                 them",
3808                short(&theirs)
3809            ));
3810        }
3811        match git::push_pinned(&repo, &remote, branch, &theirs).await {
3812            Ok(o) if o.ok() => {
3813                self.state
3814                    .event("land", format!("pushed rebased {branch} to {remote}"));
3815                None
3816            }
3817            Ok(o) => Some(format!(
3818                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3819                 someone may have pushed): {}",
3820                short(&theirs),
3821                o.stderr.chars().take(600).collect::<String>()
3822            )),
3823            Err(e) => Some(format!(
3824                "{branch} is rebased locally but could not be pushed: {e:#}"
3825            )),
3826        }
3827    }
3828
3829    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
3830    /// is already on `tip` under other commit ids ([`crate::already`]); returns
3831    /// whether it did.
3832    ///
3833    /// Checked only when the base is ahead of the branch. A failing check is
3834    /// "not proven" - the ordinary rebase path then decides - never a reason to
3835    /// stop the run.
3836    ///
3837    /// The remote copy of the branch is held to the same standard as the local
3838    /// one: if it carries a tip this worktree does not, that tip must itself be
3839    /// proven in the base, or nothing is settled (a pull request would
3840    /// otherwise be closed over commits nobody checked). The pull request is
3841    /// closed *before* the terminal status is saved; if that fails for a
3842    /// reason other than a refusal (no network, a `gh` error) the run is left
3843    /// `Blocked` with the reason as its conflict, which a resume retries -
3844    /// the same recovery a phantom conflict gets.
3845    async fn settle_already_in(
3846        &mut self,
3847        branch: &str,
3848        tip: &str,
3849        head: &str,
3850        attempts: usize,
3851        behind: usize,
3852    ) -> Result<bool> {
3853        let repo = self.state.repo.clone();
3854        let remote = self.state.config.merge.remote.clone();
3855        let start = self.state.base_commit.clone();
3856        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3857            Ok(Some(e)) => e,
3858            Ok(None) => return Ok(false),
3859            Err(e) => {
3860                tracing::warn!("already-in-base check for {branch}: {e:#}");
3861                return Ok(false);
3862            }
3863        };
3864        let mut verified = vec![head.to_owned()];
3865        let fetched = git::fetch(&repo, &remote, branch).await;
3866        if matches!(&fetched, Ok(o) if o.ok())
3867            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3868            && theirs != head
3869        {
3870            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3871                Ok(Some(_)) => verified.push(theirs),
3872                _ => return Ok(false),
3873            }
3874        }
3875        let base_branch = self.state.base_branch.clone();
3876        let message = format!(
3877            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3878             land",
3879            evidence.names(),
3880            evidence.proof.as_str()
3881        );
3882        let closed =
3883            crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3884        match closed {
3885            Ok(Ok(url)) => self
3886                .state
3887                .event("land", format!("closed {url}: superseded on {base_branch}")),
3888            Ok(Err(why)) => self
3889                .state
3890                .event("land", format!("did not close a pull request: {why}")),
3891            Err(e) => {
3892                let why = format!(
3893                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
3894                     not be closed ({e:#}); resume to retry"
3895                );
3896                self.state.status = RunStatus::Blocked;
3897                self.state.base_sync = Some(BaseSync {
3898                    tip: tip.to_owned(),
3899                    behind,
3900                    attempts,
3901                    conflict: Some(why.clone()),
3902                    already_in: None,
3903                });
3904                self.state.event("land", why);
3905                self.state.save()?;
3906                return Ok(true);
3907            }
3908        }
3909        self.state.status = RunStatus::AlreadyInBase;
3910        self.state.base_sync = Some(BaseSync {
3911            tip: tip.to_owned(),
3912            behind,
3913            attempts,
3914            conflict: None,
3915            already_in: Some(evidence),
3916        });
3917        self.state.event("land", message);
3918        self.state.save()?;
3919        self.settle_questions();
3920        Ok(true)
3921    }
3922
3923    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
3924    /// last landed the winner on, once it has run, else the commit the run
3925    /// branched from.
3926    ///
3927    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
3928    /// and `vote` all happen before there is a winner to rebase, so they
3929    /// compare every candidate against the branch point on purpose, and a
3930    /// base that moves after they are already done cannot change an answer
3931    /// they already gave.
3932    fn landing_base(&self) -> String {
3933        self.state
3934            .base_sync
3935            .as_ref()
3936            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3937    }
3938
3939    // ------------------------------------------------------- operator fix
3940
3941    /// Route specific, already-recorded review findings to a fixer for a
3942    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
3943    /// entry point.
3944    ///
3945    /// Distinct from `review_loop`'s own fix step in three ways: it never
3946    /// runs a reviewer wave, it never spends review-round budget, and what
3947    /// happened is recorded as an [`OperatorFixRequest`] appended to
3948    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
3949    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
3950    /// and vote must never be rewritten to look like a manufactured blocking
3951    /// verdict.
3952    ///
3953    /// Only meaningful once review has actually concluded: `Ready` (handed
3954    /// off with findings still open, or simply concluded clean while minor
3955    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
3956    /// gate failed). Everything else is refused: a run still in progress
3957    /// should simply be resumed, and a `Merged` run's branch has already
3958    /// landed — reopening *this* run's own record cannot change that, so the
3959    /// answer there is a fresh `magi review <branch>`.
3960    ///
3961    /// A real commit here re-verifies through a fresh, ordinary review-only
3962    /// run on the same branch ([`Self::review`]) rather than reopening this
3963    /// run's own `review_loop`: once any round in this run's history went
3964    /// clean, `review_conclusion` treats that as permanent by design (the
3965    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
3966    /// way to force one more genuine reviewer wave out of *this* run without
3967    /// either rewriting history or weakening that guarantee for every other
3968    /// caller. A review-only run costs nothing extra — no implementation, no
3969    /// judging, no vote — and exercises the exact same review → verify →
3970    /// gate → (human) merge path, unmodified.
3971    pub async fn fix_selected(
3972        &mut self,
3973        ids: &[String],
3974        reason: &str,
3975        allow_stale: bool,
3976    ) -> Result<()> {
3977        let reason = reason.trim();
3978        if reason.is_empty() {
3979            bail!("a fix request needs a reason — that is the operator's own record of why");
3980        }
3981        if ids.is_empty() {
3982            bail!("no finding id given");
3983        }
3984        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3985            bail!(
3986                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3987                 has already concluded — can be given a targeted fix. A run still \
3988                 in progress should simply be resumed; a `merged` run's branch has \
3989                 already landed, so its answer is a fresh `magi review <branch>`, \
3990                 not reopening this run's own record",
3991                self.state.id,
3992                self.state.status.as_str()
3993            );
3994        }
3995        let Some(winner) = self.state.winner().cloned() else {
3996            bail!("run {} has no winning candidate to fix", self.state.id);
3997        };
3998        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3999            bail!(
4000                "branch `{}` no longer exists; this run cannot be extended",
4001                winner.branch
4002            );
4003        }
4004        let home = crate::run::home();
4005        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4006            bail!(
4007                "run {} is currently being worked on by another magi process",
4008                self.state.id
4009            );
4010        }
4011        // Held for the rest of this call, including the follow-up review
4012        // below: two `magi fix` invocations against the same run must not
4013        // both reach the worktree manipulation further down, which would
4014        // otherwise race to remove and recreate the same directory — see
4015        // [`FixClaim`]'s own doc.
4016        let _claim = FixClaim::acquire(&self.state.dir())?;
4017
4018        // Resolve every id before spending anything — an unknown id refuses
4019        // the whole request rather than silently dropping it — and dedup
4020        // while keeping the operator's own order.
4021        let mut seen = BTreeSet::new();
4022        let mut findings = Vec::new();
4023        let mut missing = Vec::new();
4024        for id in ids {
4025            if !seen.insert(id.clone()) {
4026                continue;
4027            }
4028            match self.state.finding(id) {
4029                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4030                    id: f.id.clone(),
4031                    severity: f.severity,
4032                    reviewer_vote: rec.vote,
4033                    round: round.round,
4034                    round_head: round.head.clone(),
4035                    reviewer: rec.reviewer,
4036                    agent: rec.agent.clone(),
4037                    file: f.file.clone(),
4038                    line: f.line,
4039                    title: f.title.clone(),
4040                    detail: f.detail.clone(),
4041                    outcome: OperatorFixOutcome::Pending,
4042                }),
4043                None => missing.push(id.clone()),
4044            }
4045        }
4046        if !missing.is_empty() {
4047            bail!(
4048                "unknown finding id(s): {}; nothing was changed",
4049                missing.join(", ")
4050            );
4051        }
4052
4053        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4054        let stale_details: Vec<(String, String)> = findings
4055            .iter()
4056            .filter(|f| f.round_head != head_at_request)
4057            .map(|f| (f.id.clone(), f.round_head.clone()))
4058            .collect();
4059        let stale = !stale_details.is_empty();
4060        if stale && !allow_stale {
4061            bail!(
4062                "the branch has moved since some finding(s) were raised — {} — now \
4063                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4064                stale_details
4065                    .iter()
4066                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4067                    .collect::<Vec<_>>()
4068                    .join(", "),
4069                short(&head_at_request)
4070            );
4071        }
4072
4073        let request = OperatorFixRequest {
4074            requested_at: Timestamp::now(),
4075            reason: reason.to_owned(),
4076            findings,
4077            head_at_request: head_at_request.clone(),
4078            allow_stale,
4079            stale,
4080            fix: None,
4081            result_head: None,
4082            follow_up_review_run: None,
4083        };
4084        self.state.event(
4085            "fix",
4086            format!(
4087                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4088                request.findings.len(),
4089                request
4090                    .findings
4091                    .iter()
4092                    .map(|f| f.id.as_str())
4093                    .collect::<Vec<_>>()
4094                    .join(", "),
4095            ),
4096        );
4097        // Recorded now, before any worktree work or the fixer call itself —
4098        // and re-saved at each checkpoint below: a crash at any point after
4099        // this (mid fixer call, mid follow-up review) must not lose the fact
4100        // that this was requested, for which findings, and why. Everything
4101        // past this point reads and writes through `request_index` rather
4102        // than a local variable, since `request` itself is moved here.
4103        self.state.operator_fixes.push(request);
4104        self.state.save()?;
4105        let request_index = self.state.operator_fixes.len() - 1;
4106
4107        // A fresh, dedicated worktree for this one call, never the winner's
4108        // own worktree in place: that one may already be gone (folded away),
4109        // and reusing it in place would leave the branch checked out there
4110        // when the follow-up review below tries to check it out again. Freed
4111        // immediately after, either way — but only once confirmed clean:
4112        // `worktree_remove` is a `git worktree remove --force`, which would
4113        // otherwise discard uncommitted work left there by the operator or
4114        // another process before this had a chance to even look at it.
4115        if winner.worktree.exists() {
4116            // Lockfiles a rescue commit withheld stay untracked on purpose and
4117            // are already recorded; they are not the operator's work to protect.
4118            let dirty = git::git(
4119                &winner.worktree,
4120                &["status", "--porcelain", "--untracked-files=all"],
4121            )
4122            .await?;
4123            let only_withheld = dirty.lines().all(|l| {
4124                l.strip_prefix("?? ")
4125                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4126            });
4127            if !only_withheld {
4128                bail!(
4129                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4130                     discard them first",
4131                    winner.worktree.display()
4132                );
4133            }
4134            git::worktree_remove(&self.state.repo, &winner.worktree)
4135                .await
4136                .ok();
4137        }
4138        let fix_worktree = self.state.worktree_root().join("operator-fix");
4139        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4140        git::git(
4141            &self.state.repo,
4142            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4143        )
4144        .await
4145        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4146        if !git::is_clean(&fix_worktree).await? {
4147            git::worktree_remove(&self.state.repo, &fix_worktree)
4148                .await
4149                .ok();
4150            bail!(
4151                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4152                winner.branch
4153            );
4154        }
4155        let repo_for_setup = self.state.repo.clone();
4156        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4157            Ok(()) => {
4158                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4159            }
4160            Err(e) => Err(e),
4161        };
4162        if let Err(e) = setup {
4163            git::worktree_remove(&self.state.repo, &fix_worktree)
4164                .await
4165                .ok();
4166            return Err(e);
4167        }
4168
4169        let run_id = self.state.id.clone();
4170        let prompts = self.state.config.prompts.clone();
4171        let language = self.state.config.graph.language.clone();
4172        let sessions = self.state.config.graph.sessions;
4173        let artifacts = agent::artifacts_dir(&self.state.dir());
4174        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4175            .findings
4176            .iter()
4177            .map(|f| Finding {
4178                id: f.id.clone(),
4179                severity: f.severity,
4180                file: f.file.clone(),
4181                line: f.line,
4182                title: f.title.clone(),
4183                detail: f.detail.clone(),
4184            })
4185            .collect();
4186        let fix_prompt = prompt::operator_fix(
4187            &self.state.instruction,
4188            &finding_list,
4189            reason,
4190            &stale_details,
4191            &head_at_request,
4192            &language,
4193        );
4194        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4195        let (job, seat, out) = self
4196            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4197                prompt: fix_prompt.clone(),
4198                spec,
4199                seat,
4200                cwd: fix_worktree.clone(),
4201                timeout,
4202                allow_write: true,
4203                sessions,
4204                artifacts: artifacts.clone(),
4205                stem: "operator-fix".to_owned(),
4206                handover: None,
4207            })
4208            .await;
4209        let agent_id = seat.agent.clone();
4210
4211        let mut fix = FixRecord {
4212            agent: agent_id,
4213            addressed: Vec::new(),
4214            rejected: Vec::new(),
4215            notes: String::new(),
4216            committed: false,
4217            failed: None,
4218            duration_ms: 0,
4219            continuation: None,
4220        };
4221        let mut final_seat = seat.clone();
4222        match out {
4223            AgentOutcome::Ok(o) => {
4224                fix.duration_ms = o.duration_ms;
4225                let parsed = verdict::extract_json::<FixReport>(&o.text);
4226                let incomplete_reason = match &parsed {
4227                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4228                        "the reply parsed, but it reported a command whose own CLI \
4229                         never confirmed an exit status"
4230                            .to_owned(),
4231                    ),
4232                    Ok(_) => None,
4233                    Err(e) => Some(e.to_string()),
4234                };
4235                match incomplete_reason {
4236                    None => {
4237                        let report = parsed.expect("checked Ok above");
4238                        fix.addressed = report.addressed;
4239                        fix.rejected = report.rejected;
4240                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4241                    }
4242                    Some(reason) => {
4243                        let (resumed_seat, resolved, failure, cont) = self
4244                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4245                            .await;
4246                        fix.duration_ms += cont.cumulative_wait_ms;
4247                        fix.continuation = Some(cont);
4248                        final_seat = resumed_seat;
4249                        match resolved {
4250                            Some(report) => {
4251                                fix.addressed = report.addressed;
4252                                fix.rejected = report.rejected;
4253                                fix.notes =
4254                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4255                            }
4256                            None => fix.failed = failure,
4257                        }
4258                    }
4259                }
4260            }
4261            AgentOutcome::Dropped(o) => {
4262                fix.duration_ms = o.duration_ms;
4263                let why = o
4264                    .dropped
4265                    .as_ref()
4266                    .map(|d| d.why.as_str())
4267                    .unwrap_or("the CLI ended the stream without delivering its answer");
4268                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4269            }
4270            AgentOutcome::Quota(o) => {
4271                self.state.quota.push(QuotaLoss {
4272                    seat: final_seat.key.clone(),
4273                    node: "fix".to_owned(),
4274                    at: Timestamp::now(),
4275                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4276                });
4277                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4278            }
4279            AgentOutcome::Failed(e) => fix.failed = Some(e),
4280        }
4281        if fix.continuation.is_none() {
4282            fix.continuation = Some(ContinuationRecord::not_needed());
4283        }
4284        self.state.seats.insert(final_seat.key.clone(), final_seat);
4285
4286        let rescue_message = format!(
4287            "magi: operator-selected fix ({}) (uncommitted work)",
4288            self.state.operator_fixes[request_index]
4289                .findings
4290                .iter()
4291                .map(|f| f.id.as_str())
4292                .collect::<Vec<_>>()
4293                .join(", ")
4294        );
4295        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4296            self.state.note_withheld("fix", &r.withheld);
4297        }
4298        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4299        fix.committed = after != head_at_request;
4300        git::worktree_remove(&self.state.repo, &fix_worktree)
4301            .await
4302            .ok();
4303
4304        self.state.event(
4305            "fix",
4306            match &fix.failed {
4307                Some(reason) => format!(
4308                    "operator fix: adoption report was lost ({reason}); {}",
4309                    if fix.committed {
4310                        "committed"
4311                    } else {
4312                        "NO new commit"
4313                    }
4314                ),
4315                None => format!(
4316                    "operator fix: {} addressed, {} rejected, {}",
4317                    fix.addressed.len(),
4318                    fix.rejected.len(),
4319                    if fix.committed {
4320                        "committed"
4321                    } else {
4322                        "NO new commit"
4323                    }
4324                ),
4325            },
4326        );
4327
4328        // Every selected finding gets an outcome — never left `Pending` once
4329        // the fixer's own turn is over. A report that never came back at all
4330        // marks every one of them `Unreported`, not silently "not addressed":
4331        // quota, a dropped stream, or an exhausted continuation are gaps in
4332        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4333        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4334        for f in &mut self.state.operator_fixes[request_index].findings {
4335            f.outcome = if fix.failed.is_some() {
4336                OperatorFixOutcome::Unreported
4337            } else if fix.addressed.contains(&f.id) {
4338                OperatorFixOutcome::Addressed
4339            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4340                OperatorFixOutcome::Rejected { why: r.why.clone() }
4341            } else {
4342                OperatorFixOutcome::Unreported
4343            };
4344        }
4345
4346        let committed = fix.committed;
4347        if committed {
4348            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4349        }
4350        self.state.operator_fixes[request_index].fix = Some(fix);
4351        // Saved again now that the fixer's own outcome is final, on top of
4352        // the save right after the request was first pushed above.
4353        self.state.save()?;
4354
4355        if committed {
4356            self.state.event(
4357                "fix",
4358                format!(
4359                    "operator fix committed {}; opening a follow-up review-only run",
4360                    short(&after)
4361                ),
4362            );
4363            // The operator asked for the fix, and the follow-up serves whatever
4364            // task the run it follows served.
4365            let origin =
4366                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4367            match Self::review(
4368                &self.state.repo,
4369                &winner.branch,
4370                self.state.config.clone(),
4371                origin,
4372            )
4373            .await
4374            {
4375                Ok(mut follow_up) => {
4376                    follow_up.state.event(
4377                        "start",
4378                        format!(
4379                            "requested by an operator fix on run {} for finding(s) {}",
4380                            self.state.id,
4381                            self.state.operator_fixes[request_index]
4382                                .findings
4383                                .iter()
4384                                .map(|f| f.id.as_str())
4385                                .collect::<Vec<_>>()
4386                                .join(", "),
4387                        ),
4388                    );
4389                    follow_up.state.save()?;
4390                    let follow_up_id = follow_up.state.id.clone();
4391                    // The follow-up is a run like any other: it belongs to the
4392                    // task of the run it follows, or to one filed for it.
4393                    let adopted = match crate::direct::adopt(
4394                        &follow_up.state,
4395                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4396                    ) {
4397                        Ok(a) => a,
4398                        Err(e) => {
4399                            // An ownerless run must not spend agent calls; it
4400                            // stays saved, and `magi run --resume` adopts it.
4401                            self.state.event(
4402                                "fix",
4403                                format!(
4404                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4405                                ),
4406                            );
4407                            self.state.save()?;
4408                            return Ok(());
4409                        }
4410                    };
4411                    let executed = follow_up.execute().await;
4412                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4413                    if let Some(a) = adopted {
4414                        a.finish(&follow_up.state, executed);
4415                    }
4416                    if let Some(e) = failure {
4417                        self.state.event(
4418                            "fix",
4419                            format!(
4420                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4421                            ),
4422                        );
4423                    }
4424                    self.state.operator_fixes[request_index].follow_up_review_run =
4425                        Some(follow_up_id);
4426                }
4427                Err(e) => {
4428                    self.state.event(
4429                        "fix",
4430                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4431                    );
4432                }
4433            }
4434            self.state.save()?;
4435        }
4436
4437        Ok(())
4438    }
4439
4440    // --------------------------------------------------------------- review
4441
4442    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
4443    /// names, in order, each at most once, moving on only when the call
4444    /// advances (an error, a quota hit or nothing usable - the same decision
4445    /// point `agent::chain_advances` is for the other chained roles).
4446    ///
4447    /// Each agent gets its own seat from `Runner::seat`: the same agent
4448    /// continues its conversation (the winner's own implementer seat when it
4449    /// is the winner's author, now that the competition is over), another
4450    /// takes a fresh one so the full prompt is sent again. A handover is
4451    /// recorded under `node`, which is what makes the fallback stick for the
4452    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
4453    /// is returned, so an exhausted chain reads exactly like a single failed
4454    /// fixer: one quota loss, the same wording, the same refund. An earlier
4455    /// attempt's edits are left in the tree and judged, with the final
4456    /// attempt's, by what git says afterwards.
4457    ///
4458    /// Returns the job that produced the outcome, for `continue_fix_report`.
4459    async fn ask_fixer(
4460        &mut self,
4461        winner: &Candidate,
4462        node: &'static str,
4463        round: Option<usize>,
4464        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4465    ) -> (SeatJob, SeatState, AgentOutcome) {
4466        let run_id = self.state.id.clone();
4467        let prompts = self.state.config.prompts.clone();
4468        let cache = self.state.config.cache_dir();
4469        let attempts = fixer::attempts(&self.state, &self.roles, winner);
4470        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4471        let mut last = None;
4472        for (i, (spec, key)) in attempts.into_iter().enumerate() {
4473            let seat = self.seat(&key, &spec.id);
4474            let mut job = build(spec.clone(), seat);
4475            if i > 0 {
4476                job.stem = format!("{}-{}", job.stem, spec.id);
4477            }
4478            let ctx = WaveCtx {
4479                carry_seats: false,
4480                run: &run_id,
4481                node,
4482                prompts: &prompts,
4483                cache: cache.as_deref(),
4484                round,
4485            };
4486            let (seat, out) =
4487                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4488            let advances = match &out {
4489                AgentOutcome::Ok(o) => agent::output_advances(o),
4490                _ => true,
4491            };
4492            if let Some(next) = ids.get(i + 1)
4493                && advances
4494            {
4495                self.state.seats.insert(seat.key.clone(), seat.clone());
4496                let class =
4497                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4498                let reason = match &out {
4499                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4500                    other => fail_reason(other),
4501                };
4502                record_handover(
4503                    &mut self.state,
4504                    node,
4505                    &seat.key,
4506                    &spec.id,
4507                    next,
4508                    &class,
4509                    &reason,
4510                );
4511                continue;
4512            }
4513            last = Some((job, seat, out));
4514            break;
4515        }
4516        last.expect("the fixer chain always has an entry")
4517    }
4518
4519    async fn review_loop(&mut self) -> Result<()> {
4520        // A base that would not rebase is a person's decision, not a review
4521        // round: nothing here would change the answer, and reviewers and a
4522        // fixer would be spending real budget on a tree that cannot land
4523        // regardless of what they find.
4524        if self
4525            .state
4526            .base_sync
4527            .as_ref()
4528            .is_some_and(|s| s.conflict.is_some())
4529        {
4530            return Ok(());
4531        }
4532        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4533        // agent files with `magi task add` name the run that paid for it. The
4534        // prompt overlay is cloned alongside it because the waves borrow it
4535        // while `self` is mutably borrowed by the node's own bookkeeping.
4536        let run_id = self.state.id.clone();
4537        let prompts = self.state.config.prompts.clone();
4538        let Some(winner) = self.state.winner().cloned() else {
4539            return Ok(());
4540        };
4541        let max_rounds = self.state.config.graph.review_rounds;
4542        // A clean round, an exhausted round budget, or a stalled tree (see
4543        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4544        // they are recorded — recomputed here, not read off `status`, so a
4545        // reentry into a run that already stopped restates the identical
4546        // verdict instead of silently handing back whatever an earlier node
4547        // in this same walk clobbered `status` to (a solo-candidate
4548        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4549        // below runs an empty range once the budget is spent, and would
4550        // otherwise fall through without touching `status` at all.
4551        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4552            // A reentry after a crash between the last round's save and
4553            // `stop_reviewing` reaches the hand-off here, not there.
4554            if status == RunStatus::Gating {
4555                self.record_contested_handoff();
4556            }
4557            self.state.status = status;
4558            self.state.save()?;
4559            return Ok(());
4560        }
4561        self.state.status = RunStatus::Reviewing;
4562        // A last recorded round whose own verification never resolved
4563        // (`ResourceBlocked` — the shared build cache, not the patch) is
4564        // never a concluded round, whatever the round budget says: starting
4565        // a fresh round on top of it would spend a whole new reviewer wave
4566        // re-reading an unchanged patch instead of just retrying the one
4567        // check that actually needs it, and once the budget is spent the
4568        // loop below has nothing left to do at all (its range is empty).
4569        // Retry that check directly instead, exactly the same retry
4570        // `stop_reviewing` already does for its own catch-up case.
4571        if self
4572            .state
4573            .reviews
4574            .last()
4575            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4576        {
4577            let shell = self.state.config.shell();
4578            return self
4579                .stop_reviewing(
4580                    "the last round's own verification never resolved",
4581                    &shell,
4582                    &winner.worktree,
4583                )
4584                .await;
4585        }
4586
4587        let repo = self.state.repo.clone();
4588        let root = self.state.worktree_root();
4589        let language = self.state.config.graph.language.clone();
4590        let sessions = self.state.config.graph.sessions;
4591        let artifacts = agent::artifacts_dir(&self.state.dir());
4592        let base = self.landing_base();
4593        let base_short = short(&base);
4594        let reviewers = self.roles.reviewers.clone();
4595        let shell = self.state.config.shell();
4596
4597        for round in (self.state.reviews.len() + 1)..=max_rounds {
4598            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4599            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4600            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4601            // The prior round's own record, already persisted — never a
4602            // hand-carried variable of just its failing output: that is
4603            // exactly what let a round's e2e result drift out of sync with
4604            // which commit it was actually about (see `SCHEMA`'s doc for
4605            // schema 8). Judged against `head`, the commit reviewers are
4606            // about to look at now, so the summary always reads as "an
4607            // earlier head" here — this round's own patch has not been
4608            // checked yet.
4609            let prev_verification = self
4610                .state
4611                .reviews
4612                .last()
4613                .and_then(|r| r.verification_summary(&head));
4614
4615            // Each reviewer gets its own detached checkout of exactly this
4616            // commit: nobody can perturb the winner's tree, and the fixer can
4617            // keep working without racing a reviewer.
4618            let mut jobs = Vec::new();
4619            for (r, spec) in reviewers.iter().cloned().enumerate() {
4620                let wt = root.join(format!("review-{}", r + 1));
4621                // `clean -fdx` in a reset wipes setup's products, so every
4622                // round sets the seat up again.
4623                if wt.exists() {
4624                    git::reset_detached(&wt, &head).await?;
4625                } else {
4626                    git::worktree_add_detached(&repo, &wt, &head).await?;
4627                }
4628                self.setup_seat_worktree(&repo, &wt, false).await?;
4629                let seat_key = format!("review-{}", r + 1);
4630                // The seat starts the round on whoever answered it last, not
4631                // on the agent the spec names, so a failure is not re-paid.
4632                let spec = pick_start_spec(
4633                    &self.roles.reviewer_roster,
4634                    spec,
4635                    self.state.seat_history.get(&seat_key),
4636                );
4637                let seat = self.seat(&seat_key, &spec.id);
4638                jobs.push(SeatJob {
4639                    prompt: prompt::review(&prompt::ReviewCtx {
4640                        instruction: &self.state.instruction,
4641                        branch: &winner.branch,
4642                        base_short: &base_short,
4643                        stat: &stat,
4644                        patch: &patch,
4645                        verification: prev_verification.as_ref(),
4646                        reviewers: reviewers.len(),
4647                        round,
4648                        rounds: max_rounds,
4649                        // A review-only run has no rankings, so nothing
4650                        // competed for this patch and the reviewer is told so.
4651                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4652                        lens: Lens::for_seat(r),
4653                        language: &language,
4654                    }),
4655                    spec,
4656                    seat,
4657                    cwd: wt,
4658                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4659                    allow_write: false,
4660                    sessions,
4661                    artifacts: artifacts.clone(),
4662                    stem: format!("review-{round}-{}", r + 1),
4663                    handover: None,
4664                });
4665            }
4666
4667            self.state.event(
4668                "review",
4669                format!(
4670                    "round {round}: {} reviewers on {}",
4671                    jobs.len(),
4672                    short(&head)
4673                ),
4674            );
4675            let mut quota_losses = Vec::new();
4676            let review_retries = self.state.config.graph.retries;
4677            let review_cache = self.state.config.cache_dir();
4678            let ctx = WaveCtx {
4679                carry_seats: true,
4680                run: &run_id,
4681                node: "review",
4682                prompts: &prompts,
4683                cache: review_cache.as_deref(),
4684                round: Some(round),
4685            };
4686            let results = ask_json_wave::<Review>(
4687                jobs,
4688                Arc::clone(&self.sem),
4689                review_retries,
4690                &self.roles.reviewer_roster,
4691                &ctx,
4692                &mut quota_losses,
4693                &mut self.state,
4694                &|_: &Review| Ok(()),
4695            )
4696            .await;
4697            // Counted before the move below: how many of *this* round's
4698            // reviewer seats were lost to their own rate limit, as opposed to
4699            // a crash, a timeout, or unparsable output — see `round_is_clean`.
4700            let round_quota_missing = quota_losses.len();
4701            self.state.quota.extend(quota_losses);
4702
4703            let mut records = Vec::new();
4704            let mut all_findings = Vec::new();
4705            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4706                let agent_id = seat.agent.clone();
4707                self.state.seats.insert(seat.key.clone(), seat);
4708                let mut record = ReviewRecord {
4709                    reviewer: r + 1,
4710                    agent: agent_id,
4711                    summary: String::new(),
4712                    findings: Vec::new(),
4713                    vote: None,
4714                    failed: None,
4715                    duration_ms: 0,
4716                    // Set for both outcomes: `failed: Some(_)` with
4717                    // `attempts > 0` is a seat every retry still lost, not a
4718                    // recovered one — only `failed: None` with `attempts > 0`
4719                    // reads as "answered after a nudge" (see this field's own
4720                    // doc).
4721                    attempts,
4722                };
4723                match res {
4724                    Ok((review, out)) => {
4725                        // Sanitized here, at the point every other piece of
4726                        // agent prose in this file is (candidate summaries,
4727                        // deliberation turns, vote reasons): a reviewer's own
4728                        // words are the one thing about it that could name
4729                        // it, and reconsideration below broadcasts this same
4730                        // summary and these same findings to every other
4731                        // seat on the panel.
4732                        record.summary =
4733                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
4734                        record.vote = Some(review.vote);
4735                        record.duration_ms = out.duration_ms;
4736                        for (n, mut f) in review.findings.into_iter().enumerate() {
4737                            // ids are magi's, never the agent's: the fixer's
4738                            // adoption report is keyed by them.
4739                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4740                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4741                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4742                            // `file` is agent-supplied prose too, never
4743                            // checked against the real tree — the same
4744                            // exposure `title`/`detail` above have, just in
4745                            // a field easy to forget because it looks like a
4746                            // path rather than free text.
4747                            f.file = f
4748                                .file
4749                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4750                            all_findings.push(f.clone());
4751                            record.findings.push(f);
4752                        }
4753                        self.state.event(
4754                            "review",
4755                            format!(
4756                                "round {round}: reviewer {} voted {} with {} finding(s)",
4757                                r + 1,
4758                                review.vote.label(),
4759                                record.findings.len()
4760                            ),
4761                        );
4762                    }
4763                    Err(e) => {
4764                        record.failed = Some(e.to_string());
4765                        self.state.event(
4766                            "review",
4767                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4768                        );
4769                    }
4770                }
4771                records.push(record);
4772            }
4773
4774            // Tally the round's votes and, if they split, spend the one
4775            // round of reconsideration the split -> deliberate -> revote
4776            // shape `judge`/`vote` use for the panel, sized down to what a
4777            // read-only review round can afford: one round, and a revote
4778            // rather than an argument, because the panel already wrote its
4779            // reasoning down as findings the first time around.
4780            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4781            let vote_split =
4782                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4783            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4784            if vote_split {
4785                self.state.event(
4786                    "review",
4787                    format!(
4788                        "round {round}: votes split ({}) — one round of reconsideration",
4789                        initial_votes
4790                            .iter()
4791                            .map(|v| v.label())
4792                            .collect::<Vec<_>>()
4793                            .join(", ")
4794                    ),
4795                );
4796                // Seats read every seat's findings and votes, still numbered
4797                // and never named — the same anonymity `review` itself keeps.
4798                let panel: Vec<ReviewSeatReport<'_>> = records
4799                    .iter()
4800                    .filter_map(|r| {
4801                        r.vote.map(|vote| ReviewSeatReport {
4802                            reviewer: r.reviewer,
4803                            vote,
4804                            summary: &r.summary,
4805                            findings: &r.findings,
4806                        })
4807                    })
4808                    .collect();
4809
4810                let mut jobs = Vec::new();
4811                let mut seats_at = Vec::new();
4812                for (r, spec) in reviewers.iter().cloned().enumerate() {
4813                    // A seat with no initial vote has nothing to reconsider
4814                    // from and stays absent, the same as it stayed absent
4815                    // from `panel` above.
4816                    if records[r].vote.is_none() {
4817                        continue;
4818                    }
4819                    let wt = root.join(format!("review-{}", r + 1));
4820                    let seat_key = format!("review-{}", r + 1);
4821                    let spec = self.occupant(&seat_key, spec);
4822                    let seat = self.seat(&seat_key, &spec.id);
4823                    // A seat with no live session has already forgotten the
4824                    // initial review's prompt — restate the patch it is
4825                    // voting on, the same as `deliberate`/`vote` do for a
4826                    // judge in the same position.
4827                    // The panel already carries this seat's own review and
4828                    // vote, so restating the patch makes the prompt whole for
4829                    // a seat handed to another agent.
4830                    let build = |with_patch: bool| {
4831                        prompt::review_reconsider(&ReviewReconsiderCtx {
4832                            instruction: &self.state.instruction,
4833                            reviewer: r + 1,
4834                            lens: Lens::for_seat(r),
4835                            panel: &panel,
4836                            patch: with_patch.then_some(ReviewPatch {
4837                                branch: &winner.branch,
4838                                base_short: &base_short,
4839                                stat: &stat,
4840                                patch: &patch,
4841                            }),
4842                            round,
4843                            rounds: max_rounds,
4844                            language: &language,
4845                        })
4846                    };
4847                    let full = build(true);
4848                    let prompt = if has_context(&spec, &seat, sessions) {
4849                        build(false)
4850                    } else {
4851                        full.clone()
4852                    };
4853                    jobs.push(SeatJob {
4854                        prompt,
4855                        spec,
4856                        seat,
4857                        cwd: wt,
4858                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4859                        allow_write: false,
4860                        sessions,
4861                        artifacts: artifacts.clone(),
4862                        stem: format!("review-{round}-reconsider-{}", r + 1),
4863                        handover: Some(full),
4864                    });
4865                    seats_at.push(r);
4866                }
4867
4868                let mut recon_quota_losses = Vec::new();
4869                let recon_cache = self.state.config.cache_dir();
4870                let recon_ctx = WaveCtx {
4871                    carry_seats: true,
4872                    run: &run_id,
4873                    node: "review",
4874                    prompts: &prompts,
4875                    cache: recon_cache.as_deref(),
4876                    round: Some(round),
4877                };
4878                let recon_results = ask_json_wave::<ReviewRevote>(
4879                    jobs,
4880                    Arc::clone(&self.sem),
4881                    review_retries,
4882                    &self.roles.reviewer_roster,
4883                    &recon_ctx,
4884                    &mut recon_quota_losses,
4885                    &mut self.state,
4886                    &|_: &ReviewRevote| Ok(()),
4887                )
4888                .await;
4889                self.state.quota.extend(recon_quota_losses);
4890
4891                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4892                    let agent_id = seat.agent.clone();
4893                    self.state.seats.insert(seat.key.clone(), seat);
4894                    let mut rec = ReviewRevoteRecord {
4895                        reviewer: r + 1,
4896                        agent: agent_id,
4897                        vote: None,
4898                        reason: String::new(),
4899                        failed: None,
4900                    };
4901                    match res {
4902                        Ok((rv, _)) => {
4903                            rec.vote = Some(rv.vote);
4904                            rec.reason =
4905                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4906                            self.state.event(
4907                                "review",
4908                                format!(
4909                                    "round {round}: reviewer {} revoted {}",
4910                                    r + 1,
4911                                    rv.vote.label()
4912                                ),
4913                            );
4914                        }
4915                        Err(e) => {
4916                            rec.failed = Some(e.to_string());
4917                            self.state.event(
4918                                "review",
4919                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4920                            );
4921                        }
4922                    }
4923                    reconsideration.push(rec);
4924                }
4925            } else if initial_votes.len() > 1 {
4926                self.state.event(
4927                    "review",
4928                    format!(
4929                        "round {round}: votes agreed ({}) — no reconsideration",
4930                        initial_votes[0].label()
4931                    ),
4932                );
4933            }
4934
4935            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4936            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4937            // A round that already has a blocking finding and a round left to
4938            // try is going back to the fixer no matter what `verify.e2e`
4939            // says, so running it first only spends the loop's slowest step
4940            // (minutes, for a Rust repo's full test suite) on a head about
4941            // to be rewritten. Deferred, never skipped: `verify.e2e` still
4942            // runs once a round has no blocking findings left (see
4943            // `round_is_clean`, which a deferred — empty — `e2e` can never
4944            // satisfy since `blocking` is nonzero whenever this branch is
4945            // taken), and `stop_reviewing` forces a real run before it will
4946            // ever read a deferred round as green.
4947            let defer_e2e =
4948                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4949            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4950                let reason =
4951                    format!("{blocking} blocking finding(s) already required a fix this round");
4952                self.state.event(
4953                    "verify",
4954                    format!(
4955                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4956                         {}); it will run once a round has none left",
4957                        short(&head)
4958                    ),
4959                );
4960                (Vec::new(), false, true, Some(reason))
4961            } else {
4962                let e2e_commands = self.state.config.verify.e2e.clone();
4963                let cache_dir = self.state.config.cache_dir();
4964                let context = format!("round {round}");
4965                let (e2e, verify_retried) = with_cache_lease(
4966                    &mut self.state,
4967                    cache_dir.as_deref(),
4968                    "e2e",
4969                    "e2e",
4970                    &winner.worktree,
4971                    &head,
4972                    verify_timeout,
4973                    &context,
4974                    |state, budget| {
4975                        let shell = shell.clone();
4976                        let e2e_commands = e2e_commands.clone();
4977                        let worktree = winner.worktree.clone();
4978                        let context = context.clone();
4979                        async move {
4980                            run_e2e_with_retry(
4981                                state,
4982                                &shell,
4983                                &e2e_commands,
4984                                &worktree,
4985                                budget,
4986                                &context,
4987                            )
4988                            .await
4989                        }
4990                    },
4991                )
4992                .await;
4993                (e2e, verify_retried, false, None)
4994            };
4995
4996            let expected = records.len();
4997            let answered = records.iter().filter(|r| r.failed.is_none()).count();
4998            let incomplete = answered < expected;
4999            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5000            let policy = self.state.config.graph.incomplete_review;
5001            let clean = round_is_clean(
5002                blocking,
5003                e2e_ok,
5004                answered,
5005                expected,
5006                round_quota_missing,
5007                policy,
5008            );
5009
5010            let mut round_record = ReviewRound {
5011                round,
5012                head: head.clone(),
5013                verified_head: None,
5014                verified_at: None,
5015                reviews: records,
5016                e2e,
5017                verify_retried,
5018                e2e_deferred,
5019                e2e_defer_reason,
5020                fix: None,
5021                blocking,
5022                answered,
5023                expected,
5024                clean,
5025                progressed: false,
5026                vote_split,
5027                reconsideration,
5028                verdict: None,
5029            };
5030            // The final vote per seat is its revote where reconsideration
5031            // ran and answered, its initial vote otherwise — the same
5032            // fallback `tally` uses for a judge whose private vote failed.
5033            round_record.verdict = ReviewVote::worst(
5034                round_record
5035                    .final_votes()
5036                    .into_iter()
5037                    .map(|(_, _, vote)| vote),
5038            );
5039            // Which commit and when magi actually attempted to check —
5040            // known the moment a command was dispatched against `head`,
5041            // whether or not it finished: a resource-blocked attempt still
5042            // targeted a specific commit at a specific time, and leaving
5043            // that unrecorded is exactly what made `verification_summary`
5044            // report a fresh attempt as "commit unknown ... recorded before
5045            // this was tracked", indistinguishable from a genuinely old,
5046            // untracked record. Only a deferred or unconfigured round never
5047            // ran at all and has nothing to record — see
5048            // `ReviewRound::verified_head`'s own doc.
5049            if !matches!(
5050                round_record.e2e_status(),
5051                E2eStatus::Deferred | E2eStatus::NotConfigured
5052            ) {
5053                round_record.verified_head = Some(head.clone());
5054                round_record.verified_at = Some(Timestamp::now());
5055            }
5056            let this_round_verification = round_record.verification_summary(&head);
5057
5058            if incomplete {
5059                let missing: Vec<String> = round_record
5060                    .reviews
5061                    .iter()
5062                    .filter(|r| r.failed.is_some())
5063                    .map(|r| format!("review-{}", r.reviewer))
5064                    .collect();
5065                self.state.event(
5066                    "review",
5067                    format!(
5068                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5069                        missing.join(", ")
5070                    ),
5071                );
5072            }
5073
5074            if clean {
5075                self.state.event(
5076                    "review",
5077                    if incomplete && policy == IncompleteReviewPolicy::Warn {
5078                        format!(
5079                            "round {round}: clean (warn policy, incomplete panel) — no \
5080                             blocking findings from the seats that answered, verification green"
5081                        )
5082                    } else if incomplete {
5083                        format!(
5084                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5085                             quorum) — no blocking findings from the seats that answered, \
5086                             verification green",
5087                            expected - answered
5088                        )
5089                    } else {
5090                        format!("round {round}: clean — no blocking findings, verification green")
5091                    },
5092                );
5093                self.state.reviews.push(round_record);
5094                self.state.status = RunStatus::Gating;
5095                self.state.save()?;
5096                return Ok(());
5097            }
5098
5099            // Nothing was raised and verification passed, but not every seat
5100            // answered and `round_is_clean` still refused to call it clean —
5101            // either a seat is missing for a reason other than its own quota
5102            // (a crash, a timeout, unparsable output — worth another try), or
5103            // every seat that could have answered lost its quota and nobody
5104            // is left to decide on: re-review rather than send the fixer
5105            // after a round with nothing to fix.
5106            if incomplete && blocking == 0 && e2e_ok {
5107                self.state.reviews.push(round_record);
5108                self.state.save()?;
5109                if round == max_rounds {
5110                    self.state.status = RunStatus::Blocked;
5111                    self.state.event(
5112                        "review",
5113                        format!(
5114                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5115                             refusing to call it clean",
5116                            expected - answered
5117                        ),
5118                    );
5119                    return Ok(());
5120                }
5121                continue;
5122            }
5123
5124            // Nothing for the fixer to act on (`blocking == 0`) and the only
5125            // reason this round is not clean is that magi itself never got
5126            // a command to run — the shared build cache, not the patch (see
5127            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5128            // the fixer would invite a change to appease contention that has
5129            // nothing to do with the diff, and would leave this attempt
5130            // sitting in the next round's prompt as if it were about an
5131            // earlier, superseded commit rather than what it actually is:
5132            // the same head, still waiting to be checked. Wait for it the
5133            // same way the final round's own contention is already handled,
5134            // whatever round this happens to be.
5135            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5136                self.state.reviews.push(round_record);
5137                return self
5138                    .stop_reviewing(
5139                        "the round's own verification could not run",
5140                        &shell,
5141                        &winner.worktree,
5142                    )
5143                    .await;
5144            }
5145
5146            if round == max_rounds {
5147                self.state.reviews.push(round_record);
5148                return self
5149                    .stop_reviewing(
5150                        &format!(
5151                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5152                        ),
5153                        &shell,
5154                        &winner.worktree,
5155                    )
5156                    .await;
5157            }
5158
5159            // Fix. The winner's own implementer seat continues its conversation:
5160            // the competition is over, so context is pure benefit now.
5161            let blocking_findings: Vec<_> = all_findings
5162                .iter()
5163                .filter(|f| f.severity.blocks())
5164                .cloned()
5165                .collect();
5166            let fix_prompt = prompt::fix(
5167                &self.state.instruction,
5168                &blocking_findings,
5169                this_round_verification.as_ref(),
5170                round,
5171                max_rounds,
5172                &language,
5173            );
5174            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5175            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5176            let (job, seat, out) = self
5177                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5178                    prompt: fix_prompt.clone(),
5179                    spec,
5180                    seat,
5181                    cwd: winner.worktree.clone(),
5182                    timeout,
5183                    allow_write: true,
5184                    sessions,
5185                    artifacts: artifacts.clone(),
5186                    stem: format!("fix-{round}"),
5187                    handover: None,
5188                })
5189                .await;
5190            let agent_id = seat.agent.clone();
5191
5192            let mut fix = FixRecord {
5193                agent: agent_id,
5194                addressed: Vec::new(),
5195                rejected: Vec::new(),
5196                notes: String::new(),
5197                committed: false,
5198                failed: None,
5199                duration_ms: 0,
5200                continuation: None,
5201            };
5202            let mut continuation = ContinuationRecord::not_needed();
5203            let mut final_seat = seat.clone();
5204            match out {
5205                AgentOutcome::Ok(o) => {
5206                    fix.duration_ms = o.duration_ms;
5207                    let parsed = verdict::extract_json::<FixReport>(&o.text);
5208                    // A parsed report standing next to a command this same
5209                    // reply's own CLI never confirmed the exit status of is
5210                    // not a resolved answer — the identical `CommandEvidence`
5211                    // `state.jobs` renders, read here instead of only on
5212                    // display, per the completion judgment and the shown
5213                    // record needing to agree.
5214                    let incomplete_reason = match &parsed {
5215                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5216                            "the reply parsed, but it reported a command whose own CLI never \
5217                             confirmed an exit status"
5218                                .to_owned(),
5219                        ),
5220                        Ok(_) => None,
5221                        Err(e) => Some(e.to_string()),
5222                    };
5223                    match incomplete_reason {
5224                        None => {
5225                            let report = parsed.expect("checked Ok above");
5226                            fix.addressed = report.addressed;
5227                            fix.rejected = report.rejected;
5228                            fix.notes =
5229                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
5230                        }
5231                        Some(reason) => {
5232                            let (resumed_seat, resolved, failure, cont) = self
5233                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5234                                .await;
5235                            fix.duration_ms += cont.cumulative_wait_ms;
5236                            continuation = cont;
5237                            final_seat = resumed_seat;
5238                            match resolved {
5239                                Some(report) => {
5240                                    fix.addressed = report.addressed;
5241                                    fix.rejected = report.rejected;
5242                                    fix.notes = blind::sanitize_prose(
5243                                        &report.notes,
5244                                        &self.state.config.blind,
5245                                    );
5246                                }
5247                                None => fix.failed = failure,
5248                            }
5249                        }
5250                    }
5251                }
5252                // The CLI's raw error JSON is not a fix report to parse.
5253                AgentOutcome::Dropped(o) => {
5254                    fix.duration_ms = o.duration_ms;
5255                    let why = o
5256                        .dropped
5257                        .as_ref()
5258                        .map(|d| d.why.as_str())
5259                        .unwrap_or("the CLI ended the stream without delivering its answer");
5260                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5261                }
5262                AgentOutcome::Quota(o) => {
5263                    self.state.quota.push(QuotaLoss {
5264                        seat: final_seat.key.clone(),
5265                        node: "fix".to_owned(),
5266                        at: Timestamp::now(),
5267                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5268                    });
5269                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5270                }
5271                AgentOutcome::Failed(e) => fix.failed = Some(e),
5272            }
5273            fix.continuation = Some(continuation);
5274            self.state.seats.insert(final_seat.key.clone(), final_seat);
5275            if let Ok(r) = git::rescue_commit(
5276                &winner.worktree,
5277                &format!("magi: review round {round} fixes (uncommitted work)"),
5278            )
5279            .await
5280            {
5281                self.state.note_withheld("fix", &r.withheld);
5282            }
5283            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5284            fix.committed = after != before;
5285            // Judged by what `git` says moved against base, never by the
5286            // fixer's own `addressed`/`rejected` count — see
5287            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5288            // `patch` snapshot above: swallowing this error would default
5289            // `diff_after` to empty, which almost always differs from a
5290            // non-empty `patch` and reads as "progressed" — exactly backwards
5291            // for a `git` failure the stagnation check cannot see through.
5292            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5293            let progressed = diff_after != patch;
5294            let commit_note = if fix.committed {
5295                "committed"
5296            } else {
5297                "NO new commit"
5298            };
5299            let tree_note = if progressed {
5300                "changed vs base"
5301            } else {
5302                "unchanged vs base"
5303            };
5304            self.state.event(
5305                "fix",
5306                match &fix.failed {
5307                    // Distinct on purpose from "0 addressed, 0 rejected": the
5308                    // fixer's own diff still landed (blocking counts do keep
5309                    // falling round over round), only its adoption report did
5310                    // not come back, so this must never read like every
5311                    // finding was reviewed and declined.
5312                    Some(reason) => {
5313                        format!(
5314                            "round {round}: fixer's adoption report was lost ({reason}); \
5315                             {commit_note}, tree {tree_note}"
5316                        )
5317                    }
5318                    None => format!(
5319                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5320                         {tree_note}{}",
5321                        fix.addressed.len(),
5322                        fix.rejected.len(),
5323                        if continuation.outcome == ContinuationOutcome::Resumed {
5324                            format!(
5325                                " (adoption report recovered after {} continuation(s))",
5326                                continuation.attempts
5327                            )
5328                        } else {
5329                            String::new()
5330                        },
5331                    ),
5332                },
5333            );
5334            round_record.fix = Some(fix);
5335            round_record.progressed = progressed;
5336            self.state.reviews.push(round_record);
5337            self.state.save()?;
5338
5339            // The fixer's own report never came back this round, even after
5340            // `continue_fix_report`'s own budget was spent on it — not an
5341            // ordinary "no report" (dropped stream, quota, plain failure),
5342            // which already reads that way and is left to the existing round
5343            // budget. Stopping here, rather than opening another round, is
5344            // what keeps a next reviewer/fixer wave from ever being
5345            // dispatched onto `winner.worktree` while whatever the seat's
5346            // last call may still have running there is unaccounted for: no
5347            // process liveness check exists (and none is being added — see
5348            // AGENTS.md/this task's own scope), so the only way to honour
5349            // "nothing starts before a valid report returns" is to not start
5350            // anything further on this worktree from this run at all.
5351            if matches!(
5352                continuation.outcome,
5353                ContinuationOutcome::Exhausted
5354                    | ContinuationOutcome::QuotaLost
5355                    | ContinuationOutcome::NoSession
5356            ) {
5357                return self
5358                    .stop_reviewing(
5359                        "the fixer's adoption report never came back, even after resuming its \
5360                         own seat; refusing to start another round against the same worktree \
5361                         while that is unresolved",
5362                        &shell,
5363                        &winner.worktree,
5364                    )
5365                    .await;
5366            }
5367
5368            let streak = self
5369                .state
5370                .reviews
5371                .iter()
5372                .rev()
5373                .take_while(|r| !r.progressed)
5374                .count();
5375            if streak >= STAGNANT_LIMIT {
5376                return self
5377                    .stop_reviewing(
5378                        &format!(
5379                            "the tree has not moved against base for {streak} round(s) in a row"
5380                        ),
5381                        &shell,
5382                        &winner.worktree,
5383                    )
5384                    .await;
5385            }
5386        }
5387        Ok(())
5388    }
5389
5390    /// Decide, from the last recorded round's own verification, whether
5391    /// stopping the review loop is a hand-off or a genuine block.
5392    ///
5393    /// Called once the loop has given up trying — the round budget is spent,
5394    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5395    /// findings still open, never while a round is still clean or the
5396    /// incomplete-panel case handled inline above. Gate and e2e are facts
5397    /// about the tree; a lingering review finding is an opinion, and this
5398    /// workload's own `magi stats` puts reviewer precision low enough
5399    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5400    /// must not by itself stand between a green, verified change and the
5401    /// human who decides what to do with it. A red e2e is not an opinion, so
5402    /// that case still blocks, with the failing command and a tail of its
5403    /// output recorded here rather than left in `run.json` for someone to go
5404    /// find.
5405    ///
5406    /// A round that deferred its own e2e (see [`Config::graph`]'s
5407    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5408    /// only because nothing ran, and treating an empty list as a passing one
5409    /// here is exactly the "deferred painted green" bug this function exists
5410    /// to not have. When the last round's own verification never resolved —
5411    /// deferred on purpose, or a real attempt the shared build cache blocked
5412    /// — this makes (or retries) the real run, on the actual worktree this
5413    /// loop is about to stop touching, before deciding anything. A
5414    /// resource-blocked attempt is likewise never read as either green or
5415    /// red: it is evidence about the machine, not the patch (see
5416    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5417    /// blocked cache leaves this call without deciding rather than guessing
5418    /// — the caller retries on a later reentry.
5419    /// Record, once, that the review loop handed off over a blocking finding
5420    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5421    /// `land` asks the owner even with `land_approval` off. Called from every
5422    /// path that concludes `Gating`; a reentry keeps the first record.
5423    fn record_contested_handoff(&mut self) {
5424        if self.state.contested_handoff.is_some() {
5425            return;
5426        }
5427        let Some(contested) = self
5428            .state
5429            .reviews
5430            .last()
5431            .and_then(ReviewRound::contested_handoff)
5432        else {
5433            return;
5434        };
5435        self.state.event(
5436            "review",
5437            format!(
5438                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5439                 wait for the owner's approval",
5440                contested.findings.len(),
5441                contested.rejecters.len()
5442            ),
5443        );
5444        self.state.contested_handoff = Some(contested);
5445    }
5446
5447    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5448        let round_idx = self.state.reviews.len() - 1;
5449        // A deferred round and a resource-blocked one are the same shape
5450        // here: neither has a real result yet, and both get one more
5451        // attempt. Read off `e2e_status` — the single source for this —
5452        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5453        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5454        // still retries instead of being read as a settled result the
5455        // instant it stops being empty.
5456        let needs_catchup_run = matches!(
5457            self.state.reviews[round_idx].e2e_status(),
5458            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5459        );
5460        if needs_catchup_run {
5461            let round = self.state.reviews[round_idx].round;
5462            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5463            let commands = self.state.config.verify.e2e.clone();
5464            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5465            let cache_dir = self.state.config.cache_dir();
5466            let context = format!(
5467                "round {round}: verification unresolved, catching up before the final decision"
5468            );
5469            let (outcomes, verify_retried) = with_cache_lease(
5470                &mut self.state,
5471                cache_dir.as_deref(),
5472                "e2e",
5473                "e2e",
5474                worktree,
5475                &attempted_head,
5476                timeout,
5477                &context,
5478                |state, budget| {
5479                    let shell = shell.to_vec();
5480                    let commands = commands.clone();
5481                    let context = context.clone();
5482                    async move {
5483                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5484                            .await
5485                    }
5486                },
5487            )
5488            .await;
5489            let last = &mut self.state.reviews[round_idx];
5490            last.e2e = outcomes;
5491            last.verify_retried = verify_retried;
5492            // Always the commit and time this attempt actually targeted,
5493            // whether or not it happens to equal the reviewed `head` and
5494            // whether or not a command finished — see
5495            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5496            // attempt is recorded too, so a later reader sees "attempted
5497            // again at T2" rather than silence.
5498            last.verified_head = Some(attempted_head);
5499            last.verified_at = Some(Timestamp::now());
5500            if verify_inconclusive(&last.e2e) {
5501                // Still not a real result: `e2e_deferred` is left exactly
5502                // as it was, so `needs_catchup_run` above reads
5503                // `ResourceBlocked` (via `e2e_status`, which checks
5504                // `resource_blocked` before `e2e_deferred`) and retries
5505                // again on the next reentry, rather than recording
5506                // contention as a red e2e and blocking the run on it.
5507                self.state.save()?;
5508                return Ok(());
5509            }
5510            last.e2e_deferred = false;
5511        }
5512        let last = &self.state.reviews[round_idx];
5513        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5514
5515        match last.e2e_status() {
5516            E2eStatus::Failed => {
5517                let red: Vec<String> = last
5518                    .e2e
5519                    .iter()
5520                    .filter(|o| !o.ok())
5521                    .map(|o| {
5522                        format!(
5523                            "`{}` -> {:?}\n{}",
5524                            o.command,
5525                            o.code,
5526                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5527                        )
5528                    })
5529                    .collect();
5530                self.state
5531                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5532                self.state.status = RunStatus::Blocked;
5533            }
5534            // `needs_catchup_run` above already retried once this call; if
5535            // it is still blocked, this is magi's own admission it could
5536            // not get a command to run, never a verdict on the patch — the
5537            // run is left exactly where a later reentry can retry again.
5538            E2eStatus::ResourceBlocked => {
5539                self.state.event(
5540                    "review",
5541                    format!(
5542                        "{why}; e2e could not run (shared build cache unavailable); not \
5543                         deciding yet"
5544                    ),
5545                );
5546            }
5547            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5548                self.state.event(
5549                    "review",
5550                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5551                );
5552                self.record_contested_handoff();
5553                self.state.status = RunStatus::Gating;
5554            }
5555        }
5556        self.state.save()?;
5557        Ok(())
5558    }
5559
5560    // ----------------------------------------------------------------- gate
5561
5562    async fn gate(&mut self) -> Result<()> {
5563        // Judged by the review record itself, not by `status`: a solo
5564        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5565        // reentry (see `judge`), and trusting it here is exactly how a run
5566        // that exhausted its review budget got gated and merged a second
5567        // time around. `review_conclusion` recomputes the review loop's own
5568        // verdict from the round records themselves — `Gating` for a clean
5569        // round or a hand-off (see `stop_reviewing`), anything else means the
5570        // loop is still going or genuinely blocked.
5571        // A base the winner could not be replayed onto is a decision, not a
5572        // round: there is no landing tree to gate. Read as its own record for
5573        // the same reason the review verdict is.
5574        if self.state.status == RunStatus::Failed
5575            || self
5576                .state
5577                .base_sync
5578                .as_ref()
5579                .is_some_and(|s| s.conflict.is_some())
5580            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5581                != Some(RunStatus::Gating)
5582        {
5583            return Ok(());
5584        }
5585        if self.state.gate_ran {
5586            // `review_loop` derives its conclusion from the clean review
5587            // record on every reentry and therefore puts a completed run back
5588            // in `Gating`. A recorded gate is a stronger, terminal fact:
5589            // retain its original command output (or lack of any, for a repo
5590            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5591            // doc) and restore `Blocked` on a real failure rather than
5592            // pretending the command is still running or running it a second
5593            // time. `gate_ran == false` remains the only shape — unattempted,
5594            // or a resource-blocked retry — that may still need to execute a
5595            // command.
5596            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5597                self.state.status = RunStatus::Blocked;
5598                self.state.save()?;
5599            }
5600            return Ok(());
5601        }
5602        let Some(winner) = self.state.winner().cloned() else {
5603            return Ok(());
5604        };
5605        self.state.status = RunStatus::Gating;
5606        let mut outcomes = self.run_gate(&winner).await?;
5607        loop {
5608            // A resource-blocked outcome means the gate command never actually
5609            // ran - the shared build cache could not be acquired or confirmed
5610            // fresh in time - which is evidence about the machine, not about
5611            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5612            // Recording it as a red gate would mark a run `Blocked` on nothing
5613            // but contention magi has already logged; leaving `self.state.gate`
5614            // empty and `self.state.gate_ran` false instead keeps the shape
5615            // this function already treats as "still needs to run" (see the
5616            // early-return above), so the next call retries the command
5617            // rather than concluding anything.
5618            if verify_inconclusive(&outcomes) {
5619                self.state.save()?;
5620                return Ok(());
5621            }
5622            if outcomes.iter().all(CommandOutcome::ok) {
5623                break;
5624            }
5625            match self.gate_fix_round(&winner, &outcomes).await? {
5626                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5627                GateFix::Stop => break,
5628                GateFix::Defer => {
5629                    self.state.save()?;
5630                    return Ok(());
5631                }
5632            }
5633        }
5634        let passed = outcomes.iter().all(CommandOutcome::ok);
5635        self.state.gate = outcomes;
5636        self.state.gate_ran = true;
5637        if !passed {
5638            self.state.status = RunStatus::Blocked;
5639            let spent = self.state.gate_fixes.len();
5640            self.state.event(
5641                "gate",
5642                if spent == 0 {
5643                    "gate failed; not merging".to_owned()
5644                } else {
5645                    format!("gate failed after {spent} gate-fix round(s); not merging")
5646                },
5647            );
5648        }
5649        self.state.save()?;
5650        Ok(())
5651    }
5652
5653    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
5654    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
5655    /// after review is clean and never on a candidate awaiting judging.
5656    ///
5657    /// Never fails the run: a non-zero exit or timeout is a warning and a
5658    /// recorded outcome, and the gate remains the single arbiter. Nothing
5659    /// configured means nothing happens - no event, no commit. `commit_all`
5660    /// commits any leftover change under the neutral identity and returns
5661    /// `false` when the tree is clean, so no empty commit is ever made.
5662    async fn run_pre_gate(&mut self, winner: &Candidate) {
5663        let commands = self.state.config.verify.pre_gate.clone();
5664        if commands.is_empty() {
5665            return;
5666        }
5667        let shell = self.state.config.shell();
5668        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5669        let (outcomes, _) = run_commands(
5670            &mut self.state,
5671            "pre_gate",
5672            "pre_gate",
5673            0,
5674            &shell,
5675            &commands,
5676            &winner.worktree,
5677            timeout,
5678        )
5679        .await;
5680        for o in &outcomes {
5681            if !o.ok() {
5682                tracing::warn!(
5683                    "pre_gate `{}` failed ({:?}); the gate decides",
5684                    o.command,
5685                    o.code
5686                );
5687            }
5688            self.state.event(
5689                "pre_gate",
5690                format!(
5691                    "`{}` -> {}",
5692                    o.command,
5693                    if o.ok() {
5694                        "pass".to_owned()
5695                    } else {
5696                        format!(
5697                            "FAIL ({:?})\n{}",
5698                            o.code,
5699                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5700                        )
5701                    }
5702                ),
5703            );
5704        }
5705        self.state.pre_gate = outcomes;
5706        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5707            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5708                Ok(head) => {
5709                    self.state
5710                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
5711                    self.state.pre_gate_commit = Some(head);
5712                }
5713                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5714            },
5715            Ok(false) => {}
5716            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5717        }
5718        if let Err(e) = self.state.save() {
5719            tracing::warn!("could not persist the pre_gate record: {e:#}");
5720        }
5721    }
5722
5723    /// Run `verify.gate` once against the winner's current tree, logging one
5724    /// event per command. Empty when nothing is configured.
5725    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5726        self.run_pre_gate(winner).await;
5727        let shell = self.state.config.shell();
5728        let gate_commands = self.state.config.verify.gate.clone();
5729        // Zero commands has nothing to run and nothing that could touch the
5730        // shared build cache, so it never needs a lease: `Config::cache_dir`
5731        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
5732        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
5733        // otherwise queue behind an unrelated run's lease and come back
5734        // resource-blocked - `gate_ran` would stay false on nothing but
5735        // cache contention, for a step that had nothing to check in the
5736        // first place.
5737        let outcomes = if gate_commands.is_empty() {
5738            Vec::new()
5739        } else {
5740            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5741            let cache_dir = self.state.config.cache_dir();
5742            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5743            let (outcomes, _) = with_cache_lease(
5744                &mut self.state,
5745                cache_dir.as_deref(),
5746                "gate",
5747                "gate",
5748                &winner.worktree,
5749                &head,
5750                timeout,
5751                "final gate",
5752                |state, budget| {
5753                    let shell = shell.clone();
5754                    let gate_commands = gate_commands.clone();
5755                    let worktree = winner.worktree.clone();
5756                    async move {
5757                        let (outcomes, timed_out_pids) = run_commands(
5758                            state,
5759                            "gate",
5760                            "gate",
5761                            0,
5762                            &shell,
5763                            &gate_commands,
5764                            &worktree,
5765                            budget,
5766                        )
5767                        .await;
5768                        (outcomes, false, timed_out_pids)
5769                    }
5770                },
5771            )
5772            .await;
5773            outcomes
5774        };
5775        if outcomes.is_empty() {
5776            // Nothing configured to check — distinct from every other
5777            // silence in this run's event log, since an empty `gate` alone
5778            // no longer says whether the gate ran at all (see
5779            // `RunState::gate_ran`'s own doc).
5780            self.state.event(
5781                "gate",
5782                "no gate commands configured; nothing to check, passing",
5783            );
5784        }
5785        for o in &outcomes {
5786            self.state.event(
5787                "gate",
5788                format!(
5789                    "`{}` -> {}",
5790                    o.command,
5791                    if o.ok() {
5792                        "pass".to_owned()
5793                    } else {
5794                        format!(
5795                            "FAIL ({:?})\n{}",
5796                            o.code,
5797                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5798                        )
5799                    }
5800                ),
5801            );
5802        }
5803        Ok(outcomes)
5804    }
5805
5806    /// One bounded fix round for a failing gate.
5807    ///
5808    /// The fixer is told the failure came from the gate itself, not from a
5809    /// reviewer, and is shown the failed commands, their exit codes and a tail
5810    /// of their output - whatever `[verify].gate` holds, nothing here knows
5811    /// what those commands run. Only a normal non-zero exit that printed
5812    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
5813    /// command or a full disk says nothing about the code, and a fixer sent
5814    /// after it can only appease the machine. The round is judged by what git
5815    /// says moved, never by the fixer's own report, and `verify.e2e` runs
5816    /// again before the gate does, so a fix cannot trade a green gate for a
5817    /// red e2e unnoticed.
5818    async fn gate_fix_round(
5819        &mut self,
5820        winner: &Candidate,
5821        outcomes: &[CommandOutcome],
5822    ) -> Result<GateFix> {
5823        let cap = self.state.config.graph.gate_fix_rounds;
5824        let spent = self.state.gate_fixes.len();
5825        if spent >= cap {
5826            if cap > 0 {
5827                self.state.event(
5828                    "gate",
5829                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
5830                );
5831            }
5832            return Ok(GateFix::Stop);
5833        }
5834        if !gate_fixable(outcomes) {
5835            self.state.event(
5836                "gate",
5837                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5838                 command or similar); not spending a fix round on it",
5839            );
5840            return Ok(GateFix::Stop);
5841        }
5842        let min_free = self.state.config.disk.min_free_bytes;
5843        if min_free > 0 {
5844            match crate::disk::free_bytes(&winner.worktree) {
5845                Ok(free) if crate::disk::enough_space(free, min_free) => {}
5846                Ok(free) => {
5847                    self.state.event(
5848                        "gate",
5849                        format!(
5850                            "only {free} bytes free ({min_free} required by `[disk] \
5851                             min_free_bytes`); not spending a fix round on a failure the disk \
5852                             may explain"
5853                        ),
5854                    );
5855                    return Ok(GateFix::Stop);
5856                }
5857                Err(e) => {
5858                    self.state.event(
5859                        "gate",
5860                        format!("free disk space could not be measured ({e:#}); no fix round"),
5861                    );
5862                    return Ok(GateFix::Stop);
5863                }
5864            }
5865        }
5866
5867        let attempt = spent + 1;
5868        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5869        let base = self.landing_base();
5870        let fix_prompt = prompt::gate_fix(
5871            &self.state.instruction,
5872            &failed,
5873            attempt,
5874            cap,
5875            &self.state.config.graph.language,
5876        );
5877        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5878        let sessions = self.state.config.graph.sessions;
5879        let artifacts = agent::artifacts_dir(&self.state.dir());
5880        self.state.event(
5881            "gate",
5882            format!("gate failed; gate-fix round {attempt} of {cap}"),
5883        );
5884        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5885        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5886        let (_, seat, out) = self
5887            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
5888                prompt: fix_prompt.clone(),
5889                spec,
5890                seat,
5891                cwd: winner.worktree.clone(),
5892                timeout,
5893                allow_write: true,
5894                sessions,
5895                artifacts: artifacts.clone(),
5896                stem: format!("gate-fix-{attempt}"),
5897                handover: None,
5898            })
5899            .await;
5900        let mut record = GateFixRecord {
5901            agent: seat.agent.clone(),
5902            failed,
5903            notes: String::new(),
5904            committed: false,
5905            error: None,
5906        };
5907        match out {
5908            AgentOutcome::Ok(o) => {
5909                // A missing report is not a failed fix: the round is judged
5910                // by the tree below, and the report only carries prose.
5911                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5912                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5913                }
5914            }
5915            AgentOutcome::Dropped(_) => {
5916                record.error = Some("the CLI dropped the stream".to_owned());
5917            }
5918            AgentOutcome::Quota(o) => {
5919                self.state.quota.push(QuotaLoss {
5920                    seat: seat.key.clone(),
5921                    node: "gate-fix".to_owned(),
5922                    at: Timestamp::now(),
5923                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5924                });
5925                record.error = Some("rate limited (quota); fixer could not run".to_owned());
5926            }
5927            AgentOutcome::Failed(e) => record.error = Some(e),
5928        }
5929        self.state.seats.insert(seat.key.clone(), seat);
5930        if let Ok(r) = git::rescue_commit(
5931            &winner.worktree,
5932            &format!("magi: gate fix {attempt} (uncommitted work)"),
5933        )
5934        .await
5935        {
5936            self.state.note_withheld("gate-fix", &r.withheld);
5937        }
5938        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5939        record.committed = after != before;
5940        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5941        let note = record.error.clone();
5942        self.state.gate_fixes.push(record);
5943        self.state.save()?;
5944        if !changed {
5945            self.state.event(
5946                "gate",
5947                match note {
5948                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5949                    None => format!("gate-fix round {attempt}: the tree did not change"),
5950                },
5951            );
5952            return Ok(GateFix::Stop);
5953        }
5954        self.state.event(
5955            "gate",
5956            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5957        );
5958
5959        let commands = self.state.config.verify.e2e.clone();
5960        if !commands.is_empty() {
5961            let shell = self.state.config.shell();
5962            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5963            let cache_dir = self.state.config.cache_dir();
5964            let context = format!("gate-fix round {attempt}");
5965            let (e2e, _) = with_cache_lease(
5966                &mut self.state,
5967                cache_dir.as_deref(),
5968                "e2e",
5969                "e2e",
5970                &winner.worktree,
5971                &after,
5972                timeout,
5973                &context,
5974                |state, budget| {
5975                    let shell = shell.clone();
5976                    let commands = commands.clone();
5977                    let context = context.clone();
5978                    let worktree = winner.worktree.clone();
5979                    async move {
5980                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5981                            .await
5982                    }
5983                },
5984            )
5985            .await;
5986            if verify_inconclusive(&e2e) {
5987                return Ok(GateFix::Defer);
5988            }
5989            if e2e.iter().any(|o| !o.ok()) {
5990                self.state.event(
5991                    "gate",
5992                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5993                );
5994                return Ok(GateFix::Stop);
5995            }
5996        }
5997        Ok(GateFix::Retry)
5998    }
5999
6000    // ---------------------------------------------------------------- merge
6001
6002    /// One read-only rewrite by the summary's author, followed by a fixed fallback.
6003    async fn guarded_pr_message(
6004        &mut self,
6005        winner: &Candidate,
6006        facts: Option<&BranchFacts>,
6007        posting: bool,
6008    ) -> PrMessage {
6009        let mut pr = pr_message_raw(&self.state, winner.label, facts);
6010        if !posting {
6011            let identity = crate::scrub::Identity::current();
6012            return PrMessage {
6013                title: crate::scrub::scrub(&pr.title, &identity),
6014                body: crate::scrub::scrub(&pr.body, &identity),
6015            };
6016        }
6017        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6018        let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6019        let identity = crate::scrub::Identity::current();
6020        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6021            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6022            && !violations.contains(&crate::github_text::Violation::SensitiveData)
6023        {
6024            violations.push(crate::github_text::Violation::SensitiveData);
6025        }
6026        // Sensitive-only hits are handled by `prepare`'s span redaction; a
6027        // rewrite cannot fix them (e.g. a quoted original task) and the
6028        // fallback would discard a useful description.
6029        violations.retain(|v| *v != crate::github_text::Violation::SensitiveData);
6030        if self.state.config.graph.github_text_guard && !violations.is_empty() {
6031            self.state.event(
6032                "github-text",
6033                format!("description rejected: {violations:?}; requesting one rewrite"),
6034            );
6035            let mut rewritten = false;
6036            if let Some(spec) = self
6037                .state
6038                .config
6039                .agents
6040                .iter()
6041                .find(|a| a.id == winner.agent)
6042                .cloned()
6043            {
6044                let key = format!("impl-{}", winner.label);
6045                let seat = self.seat(&key, &spec.id);
6046                let prompt = format!(
6047                    "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{}",
6048                    pr.title, pr.body
6049                );
6050                let job = SeatJob {
6051                    spec,
6052                    seat,
6053                    cwd: winner.worktree.clone(),
6054                    prompt,
6055                    timeout: retry_budget(
6056                        Duration::from_secs(self.state.config.graph.timeout_implement),
6057                        true,
6058                    ),
6059                    allow_write: false,
6060                    sessions: self.state.config.graph.sessions,
6061                    artifacts: agent::artifacts_dir(&self.state.dir()),
6062                    stem: "github-text-rewrite".to_owned(),
6063                    handover: None,
6064                };
6065                let prompts = self.state.config.prompts.clone();
6066                let cache = self.state.config.cache_dir();
6067                let run = self.state.id.clone();
6068                let ctx = WaveCtx {
6069                    run: &run,
6070                    node: "github-text",
6071                    prompts: &prompts,
6072                    cache: cache.as_deref(),
6073                    round: None,
6074                    carry_seats: false,
6075                };
6076                let (seat, outcome) =
6077                    run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6078                self.state.seats.insert(seat.key.clone(), seat);
6079                if let AgentOutcome::Ok(output) = outcome
6080                    && let Some(title) = summary_title(&output.text)
6081                {
6082                    let mut body = summary_without_title(&output.text);
6083                    let rewrite = PrMessage {
6084                        title: title.clone(),
6085                        body: body.clone(),
6086                    };
6087                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6088                    if !body.trim().is_empty()
6089                        && crate::github_text::check_with(&title, &body, redecision).is_empty()
6090                    {
6091                        decision = redecision;
6092                        let marker = format!("magi:run/{}", self.state.id);
6093                        if !body.contains(&marker) {
6094                            body.push_str(&format!("\n\n{marker}"));
6095                        }
6096                        pr = PrMessage { title, body };
6097                        rewritten = true;
6098                        self.state
6099                            .event("github-text", "description rewrite passed");
6100                    }
6101                }
6102            }
6103            if !rewritten {
6104                self.state.event(
6105                    "github-text",
6106                    "description rewrite unavailable or rejected; using neutral body",
6107                );
6108                pr = PrMessage {
6109                    title: pr.title,
6110                    body: format!(
6111                        "{}\n\nmagi:run/{}",
6112                        crate::github_text::NEUTRAL_BODY,
6113                        self.state.id
6114                    ),
6115                };
6116                // The neutral body is fixed English; only the title's verdict
6117                // still applies.
6118                decision = decision.map(|d| crate::github_text::LanguageDecision {
6119                    body_english: true,
6120                    ..d
6121                });
6122            }
6123        }
6124        let (title, body) =
6125            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6126        PrMessage { title, body }
6127    }
6128
6129    /// Ask `[roles] language_judge` about this text and record which source
6130    /// decided; the text itself is never recorded.
6131    async fn judge_pr_language(
6132        &mut self,
6133        cwd: &Path,
6134        pr: &PrMessage,
6135    ) -> Option<crate::github_text::LanguageDecision> {
6136        self.state.config.roles.language_judge.as_ref()?;
6137        let decision =
6138            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6139        self.state.event(
6140            "github-text",
6141            if decision.is_some() {
6142                "language decided by the language judge"
6143            } else {
6144                "language judge unavailable; using built-in heuristics"
6145            },
6146        );
6147        decision
6148    }
6149
6150    async fn merge(&mut self) -> Result<()> {
6151        // Same reasoning as `gate`: ask the review and gate records directly
6152        // rather than `status`, which a solo-candidate `judge`/`deliberate`
6153        // skip can rewrite on reentry to something that no longer says
6154        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
6155        // so a hand-off (open findings, green verification) reaches merge
6156        // exactly like a genuinely clean round does.
6157        //
6158        // A run resumed mid-`land` never reaches here at all: `execute`
6159        // recognises `RunStatus::Landing` before it even calls `prep`, and
6160        // routes straight to `run_land` instead. That has to happen a level
6161        // up from this function, not with a check in here, because
6162        // `review_loop`'s own status recomputation (see its doc) runs
6163        // *before* `merge` on every reentry and would otherwise overwrite
6164        // the `Landing` marker with `Gating` before this node ever saw it.
6165        if self
6166            .state
6167            .base_sync
6168            .as_ref()
6169            .is_some_and(|s| s.conflict.is_some())
6170            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6171                != Some(RunStatus::Gating)
6172            // `gate_ran == false` is not "passed" - `gate` leaves it false
6173            // both before it has ever run and when its last attempt was
6174            // resource-blocked (see `Runner::gate`'s own doc), and neither is
6175            // permission to merge on nothing but the review record. Only a
6176            // gate that actually ran - zero commands configured and
6177            // vacuously passed, or one or more that all exited 0 - may
6178            // proceed; `RunState::gate_status` is the single place that
6179            // reading is computed.
6180            || !self.state.gate_status().ok()
6181        {
6182            return Ok(());
6183        }
6184        // This node's own record, not `status`: `status == Ready` is not
6185        // unique to the harmless `MergeMode::None` path this line was
6186        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
6187        // run's PR was closed without merging — and on that run `mode` is
6188        // still `Pr`, so a reentry that fell through here would push and
6189        // open a second pull request. `self.state.merge` is set exactly once
6190        // this node (or `land`) has already produced a verdict, under every
6191        // mode, which is what "already done" actually means here.
6192        if self.state.merge.is_some() {
6193            return Ok(());
6194        }
6195        let Some(winner) = self.state.winner().cloned() else {
6196            return Ok(());
6197        };
6198        let repo = self.state.repo.clone();
6199        let base = self.state.base_branch.clone();
6200        let mode = self.state.config.merge.mode;
6201        let style = self.state.config.merge.style;
6202        let facts = if is_review_run(&self.state) {
6203            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6204            let start = review_base(
6205                &repo,
6206                &self.state.config.merge.remote,
6207                &base,
6208                &self.state.base_commit,
6209                &winner.branch,
6210            )
6211            .await;
6212            branch_facts(&repo, &start, &winner.branch).await
6213        } else {
6214            None
6215        };
6216        // `None` when the base could not be freshly read: then an adopted
6217        // pull request's title is left alone.
6218        let leaked = if is_review_run(&self.state) {
6219            leaked_subjects(&self.state, &winner.branch).await
6220        } else {
6221            Some(Vec::new())
6222        };
6223        let pr = self
6224            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6225            .await;
6226        let message = pr.commit_message();
6227
6228        let outcome = match mode {
6229            MergeMode::None => MergeOutcome {
6230                mode,
6231                ok: true,
6232                detail: manual_merge_command(style, &repo, &winner.branch, &message),
6233                empty: false,
6234            },
6235            MergeMode::Pr | MergeMode::Local
6236                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6237            {
6238                MergeOutcome {
6239                    mode,
6240                    ok: false,
6241                    detail: empty_candidate_detail(&self.state, &base),
6242                    empty: true,
6243                }
6244            }
6245            MergeMode::Local => {
6246                let on = git::current_branch(&repo).await?;
6247                if on.as_deref() != Some(base.as_str()) {
6248                    MergeOutcome {
6249                        mode,
6250                        ok: false,
6251                        detail: format!(
6252                            "{} has {} checked out, not the base branch {base}",
6253                            repo.display(),
6254                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
6255                        ),
6256                        empty: false,
6257                    }
6258                } else if !git::is_clean(&repo).await? {
6259                    MergeOutcome {
6260                        mode,
6261                        ok: false,
6262                        detail: format!("{} is dirty; refusing to merge", repo.display()),
6263                        empty: false,
6264                    }
6265                } else {
6266                    let out = match style {
6267                        MergeStyle::Merge => {
6268                            git::merge_no_ff(&repo, &winner.branch, &message).await?
6269                        }
6270                        MergeStyle::Squash => {
6271                            git::merge_squash(&repo, &winner.branch, &message).await?
6272                        }
6273                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6274                    };
6275                    MergeOutcome {
6276                        mode,
6277                        ok: out.ok(),
6278                        detail: if out.ok() { out.stdout } else { out.stderr },
6279                        empty: false,
6280                    }
6281                }
6282            }
6283            MergeMode::Pr => {
6284                let remote = self.state.config.merge.remote.clone();
6285                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6286                if !pushed.ok() {
6287                    MergeOutcome {
6288                        mode,
6289                        ok: false,
6290                        detail: pushed.stderr,
6291                        empty: false,
6292                    }
6293                } else {
6294                    // A retry or resume of a run whose branch already has an
6295                    // open pull request adopts it rather than failing on a
6296                    // duplicate. Only this winner branch into this base:
6297                    // `branch_for` derives the name from the run id, so a
6298                    // different run's pull request never matches.
6299                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6300                    let out = match pr_merge_plan(found) {
6301                        PrPlan::Create => {
6302                            gh_pr_create(
6303                                &winner.worktree,
6304                                &base,
6305                                &winner.branch,
6306                                &pr.title,
6307                                &pr.body,
6308                            )
6309                            .await
6310                        }
6311                        PrPlan::Adopt { url, title } => {
6312                            self.state
6313                                .event("merge", format!("Pr: adopted open pull request {url}"));
6314                            if title != pr.title
6315                                && (!is_review_run(&self.state)
6316                                    || leaked
6317                                        .as_deref()
6318                                        .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6319                                && let Err(e) = land::set_pr_title(
6320                                    &mut self.state,
6321                                    &winner.worktree,
6322                                    &url,
6323                                    &pr.title,
6324                                )
6325                                .await
6326                            {
6327                                tracing::warn!("could not refresh title of {url}: {e:#}");
6328                                self.state
6329                                    .event("merge", format!("Pr: title refresh failed: {e:#}"));
6330                            }
6331                            Ok(url)
6332                        }
6333                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6334                    };
6335                    match out {
6336                        Ok(url) => MergeOutcome {
6337                            mode,
6338                            ok: true,
6339                            detail: url,
6340                            empty: false,
6341                        },
6342                        Err(e) => MergeOutcome {
6343                            mode,
6344                            ok: false,
6345                            detail: e.to_string(),
6346                            empty: false,
6347                        },
6348                    }
6349                }
6350            }
6351        };
6352
6353        self.state.status = match (mode, outcome.ok) {
6354            (MergeMode::None, _) => RunStatus::Ready,
6355            (_, true) => RunStatus::Merged,
6356            (_, false) => RunStatus::Blocked,
6357        };
6358        self.state.event(
6359            "merge",
6360            format!(
6361                "{:?}: {}",
6362                mode,
6363                outcome.detail.lines().next().unwrap_or("")
6364            ),
6365        );
6366        self.state.merge = Some(outcome);
6367        self.state.save()?;
6368
6369        // The PR is open and the run would historically stop here, leaving the
6370        // operator to watch checks, feed review comments back to a fixer, and
6371        // merge. That was done by hand six times in one session before this
6372        // existed. Opt-in, because merging is the one irreversible thing magi
6373        // can do to a repository.
6374        if self.state.config.graph.land
6375            && mode == MergeMode::Pr
6376            && self.state.status == RunStatus::Merged
6377        {
6378            self.run_land().await?;
6379        }
6380        // `run_land` may have left `status` at `Landing` - still waiting on
6381        // CI or the owner's approval, not actually settled - so this has to
6382        // read whatever `status` ended up as here, not the `Merged` this
6383        // function set a few lines up.
6384        self.settle_questions();
6385        Ok(())
6386    }
6387
6388    /// Enter `land`.
6389    ///
6390    /// Shared between a fresh run's first pass through [`Runner::merge`] and
6391    /// a resumed run's re-entry. `land::land` itself is what serialises the
6392    /// two git-mutating moments inside the loop — the rebase push and
6393    /// `gh pr merge` — per repository (see its own doc); nothing here needs
6394    /// to hold a lock across the whole call, and doing so would serialise
6395    /// this run's CI wait against a *different* run's land-approval resume
6396    /// in the same repository, which is exactly the "must not wait on
6397    /// another task" property the daemon's slot-freeing exists to give.
6398    async fn run_land(&mut self) -> Result<()> {
6399        let url = self
6400            .state
6401            .merge
6402            .as_ref()
6403            .map(|m| m.detail.clone())
6404            .unwrap_or_default();
6405        let url = url.lines().next().unwrap_or("").trim().to_owned();
6406        if !url.starts_with("http") {
6407            return Ok(());
6408        }
6409        // A land failure is not a lost run: the work is on a branch and the
6410        // pull request is open, which is exactly where a human takes over.
6411        match land::land(&mut self.state, &url).await {
6412            Ok(pr) if self.state.parked => {
6413                // `land` already saved the parked marker; nothing here
6414                // overrides `status` back to a terminal value while an
6415                // approval is still outstanding.
6416                let _ = pr;
6417            }
6418            Ok(pr) => {
6419                self.state.status = match pr.state {
6420                    land::PrLifecycle::Merged => RunStatus::Merged,
6421                    _ => RunStatus::Blocked,
6422                };
6423                // Downstream of a confirmed merge only - see
6424                // `bump::should_release_bump`'s own doc for why this one
6425                // check covers all three of `land`'s success paths.
6426                // Best-effort: the run already landed, so a failure here
6427                // (the decision call, `gh`, `cargo`) is recorded and never
6428                // turns a landed run into a failed one.
6429                if bump::should_release_bump(self.state.status)
6430                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6431                {
6432                    // The event is the run's own record. Not-eligible cases
6433                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
6434                    // `Err` is a bump that was tried and failed:
6435                    // `after_merge` itself raises the operator notice for
6436                    // that, whether or not a release PR exists yet.
6437                    self.state
6438                        .event("bump", format!("release bump skipped: {e:#}"));
6439                }
6440                // Independent of the bump, and best-effort in the same way:
6441                // findings the merge left open become follow-up tasks.
6442                if self.state.status == RunStatus::Merged {
6443                    crate::followup::after_merge(&mut self.state, &pr.url).await;
6444                }
6445                self.state.save()?;
6446            }
6447            Err(e) => {
6448                self.state.status = RunStatus::Blocked;
6449                self.state.event("land", format!("gave up: {e}"));
6450                self.state.save()?;
6451            }
6452        }
6453        Ok(())
6454    }
6455
6456    // -------------------------------------------------------------- helpers
6457
6458    /// Fetch or create a seat, keeping its conversation across nodes.
6459    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6460        if let Some(existing) = self.state.seats.get(key)
6461            && existing.agent == agent
6462        {
6463            return existing.clone();
6464        }
6465        // A seat that changes agent mints its session id from the agent too,
6466        // like a handover: the old agent's uuid is already taken by the CLI.
6467        let fresh = if self.state.seats.contains_key(key) {
6468            handover_seat(key, agent, self.state.next_seat_seed())
6469        } else {
6470            SeatState::new(key, agent, self.state.seed)
6471        };
6472        self.state.seats.insert(key.to_owned(), fresh.clone());
6473        fresh
6474    }
6475
6476    /// The agent now holding seat `key`: `spec`, unless a handover moved the
6477    /// seat to another roster agent, in which case that agent. Nodes that
6478    /// continue a seat's conversation (deliberation, the votes, a reviewer's
6479    /// reconsideration) must keep talking to whoever answered it, not slip
6480    /// back to the agent that failed it.
6481    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6482        match self.state.seats.get(key) {
6483            Some(s) if s.agent != spec.id => {
6484                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6485            }
6486            _ => spec,
6487        }
6488    }
6489
6490    /// A candidate rendered for judging, with the leak policy applied.
6491    fn view(&self, c: &Candidate) -> CandidateView {
6492        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6493            .unwrap_or_default();
6494        let (patch, _) = blind::sanitize_patch(
6495            &format!("candidate {} patch", c.label),
6496            &raw,
6497            &self.state.config.blind,
6498        );
6499        CandidateView {
6500            label: c.label,
6501            branch: c.branch.clone(),
6502            summary: c.summary.clone(),
6503            stat: c.stat.clone(),
6504            patch,
6505        }
6506    }
6507
6508    /// The full candidate set as prompt text, for seats with no live session.
6509    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6510        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6511        prompt::judge(
6512            "(see above)",
6513            &views,
6514            self.roles.judges.len(),
6515            base_short,
6516            "en",
6517        )
6518    }
6519
6520    /// The final-vote prompt with everything a seat that has no session of its
6521    /// own needs: the candidates, the seat's own ranking and reasons, and the
6522    /// anonymised deliberation it took part in (only when there was one, so a
6523    /// handed-over seat never sees more than the seat it replaces did). The
6524    /// `Final vote` heading stays first.
6525    fn vote_prompt_full(
6526        &self,
6527        j: usize,
6528        labels: &[char],
6529        language: &str,
6530        candidates: &[Candidate],
6531        base_short: &str,
6532    ) -> String {
6533        let mut text = format!(
6534            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6535            prompt::final_vote(labels, language),
6536            self.state.instruction,
6537            self.candidate_block(candidates, base_short)
6538        );
6539        if let Some(own) = self
6540            .state
6541            .judgements
6542            .get(j)
6543            .filter(|r| !r.ranking.is_empty())
6544        {
6545            let reasons = own
6546                .reasons
6547                .iter()
6548                .map(|(k, v)| format!("- {k}: {v}"))
6549                .collect::<Vec<_>>()
6550                .join("\n");
6551            text.push_str(&format!(
6552                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6553                own.ranking.iter().collect::<String>(),
6554                if reasons.is_empty() {
6555                    ""
6556                } else {
6557                    ", because:\n"
6558                }
6559            ));
6560        }
6561        if !self.state.deliberation.is_empty() {
6562            text.push_str("\n# What was argued before this vote\n");
6563            for t in self.transcript(&[], j) {
6564                text.push_str(&format!(
6565                    "\n## {}{}\n\n{}\n",
6566                    t.who,
6567                    if t.is_self { " (you)" } else { "" },
6568                    t.body.trim()
6569                ));
6570            }
6571        }
6572        text
6573    }
6574
6575    /// Anonymised transcript for judge `self_idx`.
6576    ///
6577    /// The initial rankings are always the opening statements. Seeding them
6578    /// only when no turn had been taken yet meant every judge after the first
6579    /// argued against a single voice instead of against the actual split — the
6580    /// disagreement is the information, so it is always on the table.
6581    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6582        let mut turns = Vec::new();
6583        for j in &self.state.judgements {
6584            if j.ranking.is_empty() {
6585                continue;
6586            }
6587            let reasons = j
6588                .reasons
6589                .iter()
6590                .map(|(k, v)| format!("- {k}: {v}"))
6591                .collect::<Vec<_>>()
6592                .join("\n");
6593            turns.push(Turn {
6594                who: format!("Judge {} (opening ranking)", j.judge),
6595                is_self: j.judge == self_idx + 1,
6596                body: format!(
6597                    "Ranked {}{}{reasons}",
6598                    j.ranking.iter().collect::<String>(),
6599                    if reasons.is_empty() {
6600                        ""
6601                    } else {
6602                        ", because:\n"
6603                    }
6604                ),
6605            });
6606        }
6607        for t in self
6608            .state
6609            .deliberation
6610            .iter()
6611            .flat_map(|r| r.turns.iter())
6612            .chain(current)
6613        {
6614            turns.push(Turn {
6615                who: format!("Judge {}", t.judge),
6616                is_self: t.judge == self_idx + 1,
6617                body: t.body.clone(),
6618            });
6619        }
6620        turns
6621    }
6622}
6623
6624/// Does this seat still hold the context a follow-up prompt would rely on?
6625fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6626    agent::has_session(spec.kind, seat, sessions)
6627}
6628
6629/// The next entry in `roster` after `start`, never wrapping back to the
6630/// front, whose id is not in `tried` yet.
6631///
6632/// Starts one past `start` rather than at the front of `roster`: `start` is
6633/// the seat's own original position, and a seat whose candidate slot already
6634/// sits on the roster's second entry must fall through to the third next, not
6635/// restart at the first — which is very likely a different candidate's own
6636/// agent already. Never wraps back past `start`, for the same reason: an
6637/// entry earlier in the roster than the seat's own position is almost
6638/// certainly some *other* candidate slot's own agent, and once the tail of
6639/// the roster is exhausted there are no more untried agents for *this* seat
6640/// to fall through to — the caller's fallback chain ends there, exactly as
6641/// "no further untried agents remain in the list for that seat" asks for.
6642///
6643/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
6644/// the same id twice (an operator's `roles.implementers` typo, or a
6645/// `[[agents]]` list reused across roles) must not let
6646/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
6647/// over `roster` either finds an untried id or runs out, so this always
6648/// terminates regardless of duplicates.
6649fn next_untried_in_roster<'a>(
6650    roster: &'a [AgentSpec],
6651    start: usize,
6652    tried: &BTreeSet<String>,
6653) -> Option<&'a AgentSpec> {
6654    roster
6655        .get(start + 1..)?
6656        .iter()
6657        .find(|s| !tried.contains(&s.id))
6658}
6659
6660/// The successor for a seat, shared by all four seat kinds (implement, judge,
6661/// review, advise). `others` holds the ids that *currently* occupy the other
6662/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
6663/// sees them), so roster entries beyond the seat count act as spares: a failed
6664/// seat goes to an agent no other seat holds whenever the roster permits.
6665///
6666/// `carried` is `Some` only for the review loop's carried failure history.
6667/// Order: (1) forward from `start`, never wrapping, untried, not a carried
6668/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
6669/// whole roster: untried and not another seat's occupant; (3) the plain walk
6670/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
6671/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
6672/// a better outcome than an empty seat, and it keeps the answer to "is there
6673/// a successor at all" exactly what it was before occupants were considered,
6674/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
6675/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
6676/// carried failure is a cheaper bet than doubling an agent on the panel, and
6677/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
6678/// the same round are handled one at a time, so a seat later in the batch
6679/// sees an earlier one's new occupant but not yet its own freed agent.
6680fn pick_successor<'a>(
6681    roster: &'a [AgentSpec],
6682    start: usize,
6683    tried: &BTreeSet<String>,
6684    carried: Option<&BTreeSet<String>>,
6685    others: &BTreeSet<String>,
6686) -> Option<&'a AgentSpec> {
6687    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6688    roster
6689        .get(start + 1..)
6690        .and_then(|tail| {
6691            tail.iter()
6692                .filter(free)
6693                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6694        })
6695        .or_else(|| {
6696            carried
6697                .is_some()
6698                .then(|| roster.iter().find(free))
6699                .flatten()
6700        })
6701        .or_else(|| match carried {
6702            Some(c) => next_for_seat(roster, start, tried, c),
6703            None => next_untried_in_roster(roster, start, tried),
6704        })
6705}
6706
6707/// The next agent for a seat that carries its failure history across rounds
6708/// (the review loop). `round_tried` is this round's own bound and starts
6709/// empty every round; `carried_failed` only decides priority.
6710///
6711/// First: an id walking forward from `start`, never wrapping, that is neither
6712/// tried this round nor failed in an earlier one. Only when that is exhausted
6713/// does it rescue: the first roster id (in roster order, so this one *does*
6714/// look before `start`) not yet tried this round, which is by then a carried
6715/// failure. Each id is rescued at most once per round, so it cannot loop.
6716fn next_for_seat<'a>(
6717    roster: &'a [AgentSpec],
6718    start: usize,
6719    round_tried: &BTreeSet<String>,
6720    carried_failed: &BTreeSet<String>,
6721) -> Option<&'a AgentSpec> {
6722    roster
6723        .get(start + 1..)?
6724        .iter()
6725        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6726        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6727}
6728
6729/// Where a reviewer seat starts a round: the agent that last answered it when
6730/// it is still on the roster and not marked failed, else the spec's own agent
6731/// unless it failed, else the next roster agent that has not failed, else the
6732/// spec's own agent again (the whole roster failed). Ids no longer on the
6733/// roster are ignored. An empty roster has no handover, so the spec stands.
6734fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6735    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6736        return spec;
6737    };
6738    let ok = |id: &str| !h.failed.contains(id);
6739    if let Some(last) = h.last_ok.as_deref()
6740        && ok(last)
6741        && let Some(s) = roster.iter().find(|s| s.id == last)
6742    {
6743        return s.clone();
6744    }
6745    if ok(&spec.id) {
6746        return spec;
6747    }
6748    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6749    roster
6750        .get(from + 1..)
6751        .into_iter()
6752        .flatten()
6753        .chain(roster.iter())
6754        .find(|s| ok(&s.id))
6755        .cloned()
6756        .unwrap_or(spec)
6757}
6758
6759/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
6760/// seed so a CLI that mints its session id up front (`--session-id`) never
6761/// reuses the uuid the previous agent already opened under the same seat key.
6762pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6763    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6764}
6765
6766/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
6767/// so the classifier below and the code that builds the message cannot drift.
6768const TIMED_OUT: &str = "timed out";
6769
6770impl FailClass {
6771    /// `None` for an answer; otherwise how the turn failed.
6772    fn of(out: &AgentOutcome) -> Option<Self> {
6773        match out {
6774            AgentOutcome::Ok(_) => None,
6775            AgentOutcome::Quota(_) => Some(Self::Quota),
6776            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6777            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6778            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6779        }
6780    }
6781
6782    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
6783    fn stem_word(&self) -> &'static str {
6784        match self {
6785            Self::Quota => "quota",
6786            _ => "handover",
6787        }
6788    }
6789}
6790
6791/// The message's first line with its variable parts removed — digit runs and
6792/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
6793/// as one kind of failure.
6794fn failure_signature(msg: &str) -> String {
6795    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6796    let mut out = Vec::new();
6797    for word in line.split_whitespace() {
6798        if word.contains('/') || word.contains('\\') {
6799            out.push("<path>".to_owned());
6800            continue;
6801        }
6802        let mut w = String::new();
6803        let mut in_digits = false;
6804        for c in word.chars() {
6805            if c.is_ascii_digit() {
6806                if !in_digits {
6807                    w.push('#');
6808                }
6809                in_digits = true;
6810            } else {
6811                in_digits = false;
6812                w.push(c);
6813            }
6814        }
6815        out.push(w);
6816    }
6817    out.join(" ").chars().take(120).collect()
6818}
6819
6820/// Whether a seat that just failed with `cur` may go to the next roster agent.
6821/// A quota or a timeout always may. Any other failure may not when the agent
6822/// before it failed the same way: an error the prompt causes would otherwise
6823/// walk the whole roster. `prev` is the class of the immediately preceding
6824/// agent's failure, so a quota or timeout in between breaks the run of
6825/// identical failures by itself.
6826fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6827    match cur {
6828        FailClass::Quota | FailClass::Timeout => true,
6829        FailClass::Other(_) => prev != Some(cur),
6830    }
6831}
6832
6833/// A short human reason for a failed outcome, for the handover record.
6834fn fail_reason(out: &AgentOutcome) -> String {
6835    match out {
6836        AgentOutcome::Ok(_) => String::new(),
6837        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6838        AgentOutcome::Dropped(o) => format!(
6839            "the CLI dropped the stream ({})",
6840            o.dropped
6841                .as_ref()
6842                .map(|d| d.why.as_str())
6843                .unwrap_or("it ended without delivering its answer")
6844        ),
6845        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6846    }
6847}
6848
6849/// Note one handover in the run: the structured record and, in the timeline,
6850/// the sentence a person reads. A quota keeps the wording it always had.
6851pub(crate) fn record_handover(
6852    state: &mut RunState,
6853    node: &str,
6854    seat: &str,
6855    from: &str,
6856    to: &str,
6857    class: &FailClass,
6858    reason: &str,
6859) {
6860    let message = if *class == FailClass::Quota {
6861        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6862    } else {
6863        format!("{seat}: handed over {from} -> {to} ({reason})")
6864    };
6865    state.event(node, message);
6866    state.handovers.push(Handover {
6867        at: Timestamp::now(),
6868        node: node.to_owned(),
6869        seat: seat.to_owned(),
6870        from: from.to_owned(),
6871        to: to.to_owned(),
6872        reason: reason.to_owned(),
6873    });
6874}
6875
6876/// Did this reply report running a command whose own CLI never confirmed an
6877/// exit status?
6878///
6879/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
6880/// command *finished* (see that type's own doc), so this can only be `true`
6881/// for a command whose completion event carried no readable exit code — not
6882/// for one that simply is not mentioned at all. That is the one signal this
6883/// crate can read, from the same record `state.jobs` renders, about a reply
6884/// standing next to work its own CLI cannot vouch for finishing; it is
6885/// deliberately not a check on the exit code's *value* (a fixer legitimately
6886/// runs a command that fails mid-iteration before it succeeds) and not a
6887/// guess at a command still running in the background (which emits no event
6888/// at all, and so leaves no evidence here to find).
6889fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6890    commands.iter().any(|c| c.exit_code.is_none())
6891}
6892
6893/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
6894/// trusted as a verified no-op — the adoption guard's own text-level half.
6895///
6896/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
6897/// timed out): a marker only earns the benefit of the doubt from a turn the
6898/// CLI itself vouches for finishing properly, the same house style
6899/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
6900/// *fix* report to for `commands`. A candidate that timed out, exited
6901/// non-zero, or left a command unconfirmed is read as the ordinary loss it
6902/// is, whatever prose it wrote — this returns `None` before it ever looks at
6903/// `text`. The remaining guards (the tree really is empty, the evidence is
6904/// non-empty) are the caller's: this only reads what the reply *claimed*.
6905fn verified_noop_claim(
6906    usable: bool,
6907    commands: &[agent::CommandEvidence],
6908    text: &str,
6909) -> Option<String> {
6910    (usable && !has_unconfirmed_command(commands))
6911        .then(|| verdict::verified_noop(text))
6912        .flatten()
6913}
6914
6915fn short(commit: &str) -> String {
6916    commit.chars().take(7).collect()
6917}
6918
6919fn make_executable(path: &Path) -> Result<()> {
6920    #[cfg(unix)]
6921    {
6922        use std::os::unix::fs::PermissionsExt as _;
6923        let mut perms = std::fs::metadata(path)?.permissions();
6924        perms.set_mode(0o755);
6925        std::fs::set_permissions(path, perms)?;
6926    }
6927    #[cfg(not(unix))]
6928    {
6929        let _ = path;
6930    }
6931    Ok(())
6932}
6933
6934/// What every seat in one batch shares: where the answers are attributed, the
6935/// prompt overlay they inherit, and the build cache they are told to use.
6936///
6937/// A struct rather than four more parameters: `wave` also needs the run's
6938/// state (to record who is answering right now) and the attempt number, and
6939/// eight positional arguments is both unreadable and a clippy error.
6940struct WaveCtx<'a> {
6941    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
6942    /// paid for it.
6943    run: &'a str,
6944    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
6945    node: &'a str,
6946    prompts: &'a Prompts,
6947    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
6948    cache: Option<&'a Path>,
6949    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
6950    /// `JobRecord::round`. `None` for every other node.
6951    round: Option<usize>,
6952    /// Carry each seat's failed-agent history across waves (the review loop
6953    /// only): start-of-round priority and handover choice read
6954    /// [`RunState::seat_history`], and every answer or failure writes it.
6955    carry_seats: bool,
6956}
6957
6958/// Run one job, honouring the parallelism budget.
6959async fn run_one(
6960    job: SeatJob,
6961    sem: Arc<Semaphore>,
6962    ctx: &WaveCtx<'_>,
6963    state: &mut RunState,
6964    attempt: usize,
6965) -> (SeatState, AgentOutcome) {
6966    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6967        .await
6968        .pop()
6969        .expect("one job in, one result out");
6970    (seat, out)
6971}
6972
6973/// Run every job concurrently, capped by the semaphore, preserving order.
6974///
6975/// Every seat in the batch is recorded into [`RunState::active`] before the
6976/// wave starts and cleared as each answer lands, so the run's own record says
6977/// who is still being waited on rather than only who finished.
6978async fn wave(
6979    jobs: Vec<SeatJob>,
6980    sem: Arc<Semaphore>,
6981    ctx: &WaveCtx<'_>,
6982    state: &mut RunState,
6983    attempt: usize,
6984) -> Vec<(usize, SeatState, AgentOutcome)> {
6985    let WaveCtx {
6986        run,
6987        node,
6988        prompts,
6989        cache,
6990        round,
6991        carry_seats: _,
6992    } = *ctx;
6993    for job in &jobs {
6994        state.seat_started(node, &job.seat.key, job.timeout, attempt);
6995    }
6996    if let Err(e) = state.save() {
6997        // A failed persist of "who is answering right now" must not abort the
6998        // wave: the seats are already being asked, and the alternative is
6999        // losing the answers to save a status line nobody may even be
7000        // watching.
7001        tracing::warn!("could not persist in-progress seats: {e:#}");
7002    }
7003    // Hold the shared build cache's lease for the whole batch, not per job:
7004    // several candidates (an implement wave) or a fixer legitimately share
7005    // one cache concurrently within this run, and that stays untouched — a
7006    // single lease taken once for the whole wave and released once it is
7007    // done is what stops a *different* borrower (another run's own wave, its
7008    // e2e/gate, a human's `magi review`) from interleaving a build into the
7009    // same directory while this one is in flight. Best-effort, not
7010    // all-or-nothing: a wave that cannot get the lease within its own
7011    // longest job's budget still runs — an hour of paid implementer calls is
7012    // not thrown away over cache contention — but every write-allowed seat
7013    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
7014    // below), the same fallback a read-only seat always gets, rather than
7015    // building into a directory this run was never granted. The identity
7016    // record is still invalidated below either way, so the next tracked
7017    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
7018    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7019    let wait_started = Instant::now();
7020    let cache_guard = if let Some(cache_dir) = cache {
7021        if jobs_had_a_writer {
7022            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7023            let budget = jobs
7024                .iter()
7025                .map(|j| j.timeout)
7026                .max()
7027                .unwrap_or(Duration::from_secs(60));
7028            acquire_cache_lease(state, cache_dir, &owner, budget, node)
7029                .await
7030                .ok()
7031        } else {
7032            None
7033        }
7034    } else {
7035        None
7036    };
7037    // Carved out of each job's own budget, not added on top of it: a seat
7038    // that waited behind the lease must not also get its full timeout
7039    // afterward, or a run contended on the cache could double the time it
7040    // spends per wave. `saturating_sub` floors at zero rather than
7041    // wrapping - a job whose whole budget was spent waiting starts with
7042    // none left, which is the honest number, not a free minimum.
7043    let waited_for_lease = wait_started.elapsed();
7044    let mut set = tokio::task::JoinSet::new();
7045    let overlay = prompts.overlay(node);
7046    for (i, mut job) in jobs.into_iter().enumerate() {
7047        job.timeout = job.timeout.saturating_sub(waited_for_lease);
7048        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7049        if cache.is_some() {
7050            job.prompt.push('\n');
7051            job.prompt
7052                .push_str(&prompt::build_cache_note(node, job.allow_write));
7053        }
7054        let sem = Arc::clone(&sem);
7055        let run = run.to_owned();
7056        let node = node.to_owned();
7057        // Only implementers were told about the task's attachments, so only
7058        // their seats get the directory widened for reading.
7059        let attachments = if node == "implement" {
7060            state.attachments.clone()
7061        } else {
7062            Vec::new()
7063        };
7064        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
7065        // `prompt::build_cache_note`'s doc for why setting it anyway is
7066        // exactly how a sandboxed reviewer's write refusal got reported as a
7067        // defect in the patch, not a property of its own seat. And a
7068        // write-allowed one is handed it only when the lease above was
7069        // actually acquired: a wave that could not get it (`cache_guard` is
7070        // `None`, see its own comment) must not send seats to build into a
7071        // directory this run does not hold - that is the exact concurrent,
7072        // unmanaged-write race this module exists to prevent, not something
7073        // "proceeding anyway" is allowed to reintroduce.
7074        let cache = cache
7075            .filter(|_| job.allow_write && cache_guard.is_some())
7076            .map(Path::to_path_buf);
7077        set.spawn(async move {
7078            let _permit = sem.acquire().await;
7079            let mut seat = job.seat;
7080            let out = agent::invoke(
7081                &job.spec,
7082                &mut seat,
7083                &Invocation {
7084                    cwd: &job.cwd,
7085                    prompt: &job.prompt,
7086                    timeout: job.timeout,
7087                    allow_write: job.allow_write,
7088                    unsandboxed: false,
7089                    sessions: job.sessions,
7090                    artifacts: &job.artifacts,
7091                    stem: &job.stem,
7092                    run: &run,
7093                    node: &node,
7094                    cache_dir: cache.as_deref(),
7095                    attachments: &attachments,
7096                    writable: &[],
7097                },
7098            )
7099            .await;
7100            let out = match out {
7101                Ok(o) if o.usable() => AgentOutcome::Ok(o),
7102                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7103                // Billed work the CLI failed to hand over is not an ordinary
7104                // failure, but its text is the CLI's raw error JSON, not an
7105                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
7106                // read it as one by forgetting to check. `usable()` is always
7107                // false here (dropped implies an empty response), so this has
7108                // to be checked before the catch-all `Failed` below or the
7109                // one shape this exists for is lost with the rest.
7110                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7111                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7112                Ok(o) => AgentOutcome::Failed(format!(
7113                    "exited with {:?} and no usable output",
7114                    o.exit_code
7115                )),
7116                Err(e) => AgentOutcome::Failed(e.to_string()),
7117            };
7118            (i, seat, out)
7119        });
7120    }
7121    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7122    while let Some(joined) = set.join_next().await {
7123        let (i, seat, out) = match joined {
7124            Ok(v) => v,
7125            // No seat to clear: a panicked task never reported which one it
7126            // was. The defensive sweep below this loop is what stops that
7127            // seat's `active` entry from surviving forever.
7128            Err(e) => {
7129                tracing::error!("agent task panicked: {e}");
7130                continue;
7131            }
7132        };
7133        state.seat_finished(&seat.key);
7134        record_jobs(state, node, round, &seat.key, &out);
7135        if let Err(e) = state.save() {
7136            tracing::warn!("could not persist a seat's completion: {e:#}");
7137        }
7138        if collected.len() <= i {
7139            collected.resize_with(i + 1, || None);
7140        }
7141        collected[i] = Some((i, seat, out));
7142    }
7143    // Belt-and-braces for the panic branch above: every seat this exact batch
7144    // started shares this `(node, attempt)` pair, and every seat that finished
7145    // normally already cleared itself, so anything left tagged with it here
7146    // can only be a panicked task's leftover. Cleared unconditionally rather
7147    // than left to read as still answering forever.
7148    if state
7149        .active
7150        .values()
7151        .any(|a| a.node == node && a.attempt == attempt)
7152    {
7153        state
7154            .active
7155            .retain(|_, a| !(a.node == node && a.attempt == attempt));
7156        if let Err(e) = state.save() {
7157            tracing::warn!("could not persist the end of a wave: {e:#}");
7158        }
7159    }
7160    // Whether or not the lease above was actually held, several worktrees
7161    // may just have built into the cache with nothing here able to name one
7162    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
7163    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
7164    // clean it might not have strictly needed; trusting a stale match would
7165    // cost it a wrong answer.
7166    if let Some(cache_dir) = cache
7167        && jobs_had_a_writer
7168    {
7169        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7170    }
7171    if let Some(guard) = cache_guard {
7172        guard.release();
7173    }
7174    collected.into_iter().flatten().collect()
7175}
7176
7177/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
7178/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
7179/// this is data collection, not the fix-specific completion contract in
7180/// [`Runner::continue_fix_report`], and applies regardless of which node
7181/// asked.
7182///
7183/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
7184/// evidence from; `Failed` does not, and correctly contributes nothing — a
7185/// timeout or crash is not itself evidence about a command the seat may have
7186/// started.
7187fn record_jobs(
7188    state: &mut RunState,
7189    node: &str,
7190    round: Option<usize>,
7191    seat: &str,
7192    out: &AgentOutcome,
7193) {
7194    let commands: &[agent::CommandEvidence] = match out {
7195        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7196        AgentOutcome::Failed(_) => &[],
7197    };
7198    let checked_at = Timestamp::now();
7199    for c in commands {
7200        state.jobs.push(JobRecord {
7201            node: node.to_owned(),
7202            round,
7203            seat: seat.to_owned(),
7204            id: c.id.clone(),
7205            description: c.description.clone(),
7206            checked_at,
7207            status: match c.exit_code {
7208                Some(0) => JobStatus::Completed,
7209                Some(_) => JobStatus::Failed,
7210                None => JobStatus::Unknown,
7211            },
7212            exit_code: c.exit_code,
7213            result_summary: c.result_summary.clone(),
7214            source: c.source.clone(),
7215        });
7216    }
7217}
7218
7219/// Is a review round clean, given how many reviewer seats answered against
7220/// how many the round expected?
7221///
7222/// A seat that never answered (timeout, crash, unparsable output) is not a
7223/// seat that read the patch and found nothing — treating it as such is
7224/// exactly the bug this function exists to close. Under the default `block`
7225/// policy a missing seat can never be clean; `warn` still requires the seats
7226/// that *did* answer to have found nothing blocking and verification to be
7227/// green.
7228///
7229/// `quota_missing` narrows that `block` default for exactly one cause of
7230/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
7231/// a session limit lifts by the very next round buys nothing — the seat is
7232/// asked again with the same quota — so once every missing seat is accounted
7233/// for by a quota loss (and at least one seat *did* answer, so a decision has
7234/// something to rest on) the round is decided on the panel that could answer,
7235/// same as `warn` would. A panel that lost every seat to quota is not
7236/// decided here: `answered == 0` falls through to the existing `block`
7237/// fallback so a fully collapsed panel still waits rather than landing on no
7238/// review at all.
7239fn round_is_clean(
7240    blocking: usize,
7241    e2e_ok: bool,
7242    answered: usize,
7243    expected: usize,
7244    quota_missing: usize,
7245    policy: IncompleteReviewPolicy,
7246) -> bool {
7247    if blocking != 0 || !e2e_ok {
7248        return false;
7249    }
7250    if answered == expected || policy == IncompleteReviewPolicy::Warn {
7251        return true;
7252    }
7253    answered > 0 && expected - answered <= quota_missing
7254}
7255
7256/// The review loop's own conclusion, derived entirely from its persisted
7257/// round records and the round budget that produced them — never from
7258/// `status`, so a reentry (or `gate`/`merge` reading it independently)
7259/// recomputes the identical answer regardless of what an earlier node in the
7260/// same walk, or a previous walk, did to `status`.
7261///
7262/// `None` while more rounds remain to try, including when review never ran
7263/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
7264/// gone clean, or the budget is spent, or the tree has stopped moving (see
7265/// [`STAGNANT_LIMIT`]), the answer is one of two things:
7266///
7267/// - An incomplete panel that raised nothing is missing input, not a
7268///   verified tree — never a hand-off candidate, whatever verification said
7269///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
7270/// - Otherwise, green e2e on the last round hands off (see
7271///   [`Runner::stop_reviewing`]); red e2e blocks.
7272///
7273/// A last round whose own verification is still `ResourceBlocked` — magi
7274/// itself never got a command to run, not evidence the patch is broken —
7275/// is neither: this returns `None` for it too, the same as "more rounds
7276/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
7277/// handling of that shape) instead of this cheap recomputation guessing a
7278/// verdict a real attempt never produced.
7279fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7280    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7281        return Some(RunStatus::Gating);
7282    }
7283    let last = reviews.last()?;
7284    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7285    if reviews.len() < max_rounds && !stagnant {
7286        return None;
7287    }
7288    if last.incomplete() && last.blocking == 0 {
7289        return Some(RunStatus::Blocked);
7290    }
7291    if last.e2e_status() == E2eStatus::ResourceBlocked {
7292        return None;
7293    }
7294    Some(if last.e2e.iter().all(CommandOutcome::ok) {
7295        RunStatus::Gating
7296    } else {
7297        RunStatus::Blocked
7298    })
7299}
7300
7301/// How long a re-ask may take, given the budget the first attempt had.
7302///
7303/// A `nudged` retry is a request to restate an answer the seat has already
7304/// worked out: it carries no new work, so it does not deserve the original
7305/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
7306/// 133 seconds while a third sat for over ten minutes on a resumed session
7307/// holding 410 KB of prior output - and because the retry had inherited the
7308/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
7309/// judging round whose other seats were long finished.
7310///
7311/// A quarter of the budget, with a floor so that a deliberately short timeout
7312/// does not collapse to nothing. A retry that re-sends the whole prompt
7313/// (because the seat kept no context) is the original job again, and keeps the
7314/// original budget.
7315pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7316    if nudged {
7317        (full / 4).max(Duration::from_secs(120)).min(full)
7318    } else {
7319        full
7320    }
7321}
7322
7323/// Run a wave and parse each reply, re-asking the seats whose reply was
7324/// unusable.
7325///
7326/// The re-ask is a nudge rather than the whole prompt again when the seat still
7327/// holds its conversation, which is the difference between a cheap retry and
7328/// paying for the entire candidate set twice.
7329///
7330/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
7331/// successor in `roster` is handed to it instead of being re-asked: the
7332/// handover is the retry. A seat with no successor left (a single-agent
7333/// roster, the roster's tail) is nudged as before, up to `retries` times. So
7334/// the asks to one seat in one node number at most
7335/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
7336/// is asked once (a dropped stream is nudged first), and only the last agent
7337/// of the chain gets the `retries` same-agent nudges. Each roster agent is
7338/// tried at most once per seat, walking forward from the seat's own position and never wrapping
7339/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
7340/// other failure stops the chain when the previous agent failed the same way
7341/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
7342/// [`has_context`] is false and the job's own full prompt and full budget are
7343/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
7344/// intermediate ones are not losses) and is returned as a failure like any
7345/// other absent seat — the caller decides whether the panel still has a
7346/// quorum. An empty `roster` disables handover: failures are nudged as they
7347/// always were, and a quota is simply lost. A reply that fails to parse or
7348/// validate is the prompt's doing and is only ever nudged, never handed over.
7349///
7350/// The returned [`SeatState`] names the agent that answered (or tried last).
7351#[allow(clippy::too_many_arguments)]
7352async fn ask_json_wave<T>(
7353    jobs: Vec<SeatJob>,
7354    sem: Arc<Semaphore>,
7355    retries: usize,
7356    roster: &[AgentSpec],
7357    ctx: &WaveCtx<'_>,
7358    losses: &mut Vec<QuotaLoss>,
7359    state: &mut RunState,
7360    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7361) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7362where
7363    T: serde::de::DeserializeOwned + Send + 'static,
7364{
7365    ask_wave_with(
7366        jobs,
7367        sem,
7368        retries,
7369        roster,
7370        ctx,
7371        losses,
7372        state,
7373        &|text: &str| {
7374            let v = verdict::extract_json::<T>(text)?;
7375            validate(&v)?;
7376            Ok(v)
7377        },
7378    )
7379    .await
7380}
7381
7382/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
7383/// a node whose answer is prose (deliberation) shares the same handover,
7384/// failure classification, quota bookkeeping and bounds instead of a copy.
7385///
7386/// A seat handed to another roster agent is sent the job's `handover` prompt
7387/// (when it has one) rather than `prompt`: the new agent has no session, so a
7388/// resume-style prompt would be incomplete. That holds for the handover ask
7389/// and for every nudge to that agent whose `has_context` is false.
7390#[allow(clippy::too_many_arguments)]
7391async fn ask_wave_with<T>(
7392    jobs: Vec<SeatJob>,
7393    sem: Arc<Semaphore>,
7394    retries: usize,
7395    roster: &[AgentSpec],
7396    ctx: &WaveCtx<'_>,
7397    losses: &mut Vec<QuotaLoss>,
7398    state: &mut RunState,
7399    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7400) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7401where
7402    T: Send + 'static,
7403{
7404    let n = jobs.len();
7405    let originals: Vec<SeatJob> = jobs;
7406    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7407    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7408    // Nudges each seat's *current* agent has taken — 0 for a first-ask
7409    // answer, N once it has gone through N nudges. Read back once this
7410    // returns, so a caller building a history record (`ReviewRecord`) can
7411    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
7412    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
7413    // field's own doc.
7414    let mut nudges: Vec<usize> = vec![0; n];
7415    // The agent now occupying each seat, the ids it has already been through,
7416    // where in the roster the walk began, the class of the last failure, and
7417    // the stem word of a handover not yet asked (full prompt, full budget).
7418    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7419    let mut tried: Vec<BTreeSet<String>> = specs
7420        .iter()
7421        .map(|s| BTreeSet::from([s.id.clone()]))
7422        .collect();
7423    let starts: Vec<usize> = specs
7424        .iter()
7425        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7426        .collect();
7427    let carry = ctx.carry_seats && !roster.is_empty();
7428    // Carried across rounds: ids that failed the seat earlier, and how the
7429    // last failure went (so a repeat of it is not handed over again).
7430    let carried: Vec<BTreeSet<String>> = originals
7431        .iter()
7432        .map(|j| {
7433            state
7434                .seat_history
7435                .get(&j.seat.key)
7436                .filter(|_| carry)
7437                .map(|h| h.failed.clone())
7438                .unwrap_or_default()
7439        })
7440        .collect();
7441    let mut prev: Vec<Option<FailClass>> = originals
7442        .iter()
7443        .map(|j| {
7444            state
7445                .seat_history
7446                .get(&j.seat.key)
7447                .filter(|_| carry)
7448                .and_then(|h| h.last_fail.clone())
7449        })
7450        .collect();
7451    let next_agent =
7452        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7453            let others: BTreeSet<String> = specs
7454                .iter()
7455                .enumerate()
7456                .filter(|(j, _)| *j != i)
7457                .map(|(_, s)| s.id.clone())
7458                .collect();
7459            pick_successor(
7460                roster,
7461                starts[i],
7462                tried,
7463                carry.then(|| &carried[i]),
7464                &others,
7465            )
7466            .cloned()
7467        };
7468    let mut fresh: Vec<Option<String>> = vec![None; n];
7469    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7470    let mut pending: Vec<usize> = (0..n).collect();
7471
7472    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
7473    // asks: an agent with a successor is asked once and handed over, and only
7474    // a seat with no successor spends `retries` nudges on the same agent. This
7475    // only guarantees the loop's own termination whatever those say.
7476    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7477    for round in 0..max_rounds {
7478        if pending.is_empty() {
7479            break;
7480        }
7481        let mut batch = Vec::with_capacity(pending.len());
7482        let mut renudged: Vec<&str> = Vec::new();
7483        for &i in &pending {
7484            let src = &originals[i];
7485            // A seat now held by another agent than the job named has no
7486            // session of its own: it gets the full-context prompt whenever
7487            // it is asked in full (the handover ask, a nudge it cannot
7488            // resume).
7489            let full: &str = match &src.handover {
7490                Some(h) if specs[i].id != src.spec.id => h,
7491                _ => &src.prompt,
7492            };
7493            // The prompt and the budget are one decision: a nudge restates
7494            // finished work, a re-sent prompt redoes it.
7495            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7496                (
7497                    full.to_owned(),
7498                    src.timeout,
7499                    format!("{}-{word}-{}", src.stem, specs[i].id),
7500                )
7501            } else if nudges[i] == 0 {
7502                (full.to_owned(), src.timeout, src.stem.clone())
7503            } else {
7504                renudged.push(src.seat.key.as_str());
7505                let why = done[i]
7506                    .as_ref()
7507                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
7508                    .unwrap_or_else(|| "no parsable answer".to_owned());
7509                let nudge = prompt::nudge(&why);
7510                let nudged = has_context(&specs[i], &seats[i], src.sessions);
7511                let prompt = if nudged {
7512                    nudge
7513                } else {
7514                    format!("{full}\n\n---\n\n{nudge}")
7515                };
7516                (
7517                    prompt,
7518                    retry_budget(src.timeout, nudged),
7519                    format!("{}-retry{}", src.stem, nudges[i]),
7520                )
7521            };
7522            batch.push(SeatJob {
7523                spec: specs[i].clone(),
7524                seat: seats[i].clone(),
7525                cwd: src.cwd.clone(),
7526                prompt,
7527                timeout,
7528                allow_write: src.allow_write,
7529                sessions: src.sessions,
7530                artifacts: src.artifacts.clone(),
7531                stem,
7532                handover: None,
7533            });
7534        }
7535
7536        if !renudged.is_empty() {
7537            state.event(
7538                ctx.node,
7539                format!("retry {round}: re-asking {}", renudged.join(", ")),
7540            );
7541        }
7542        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7543        let mut still = Vec::new();
7544        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7545            seats[i] = seat;
7546            let class = FailClass::of(&out);
7547            // A dropped stream is nudged first (the conversation is still
7548            // there to pick up); only a seat whose nudges are spent hands over.
7549            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7550                && nudges[i] < retries
7551                && !roster.is_empty();
7552            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7553                let next = should_hand_over(prev[i].as_ref(), &cur)
7554                    .then(|| next_agent(i, &tried[i], &specs))
7555                    .flatten();
7556                if carry {
7557                    let h = state
7558                        .seat_history
7559                        .entry(originals[i].seat.key.clone())
7560                        .or_default();
7561                    h.failed.insert(specs[i].id.clone());
7562                    h.last_fail = Some(cur.clone());
7563                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7564                        h.last_ok = None;
7565                    }
7566                    // Saved before the next agent is asked, so a restart in
7567                    // between does not forget who failed.
7568                    if let Err(e) = state.save() {
7569                        tracing::warn!("could not persist a seat's failure history: {e:#}");
7570                    }
7571                }
7572                if let Some(next) = next {
7573                    record_handover(
7574                        state,
7575                        ctx.node,
7576                        &originals[i].seat.key,
7577                        &specs[i].id,
7578                        &next.id,
7579                        &cur,
7580                        &fail_reason(&out),
7581                    );
7582                    tried[i].insert(next.id.clone());
7583                    prev[i] = Some(cur.clone());
7584                    seats[i] =
7585                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7586                    specs[i] = next;
7587                    fresh[i] = Some(cur.stem_word().to_owned());
7588                    nudges[i] = 0;
7589                    done[i] = Some(Err(anyhow::anyhow!(
7590                        "handed over after: {}",
7591                        fail_reason(&out)
7592                    )));
7593                    still.push(i);
7594                    continue;
7595                }
7596            }
7597            if carry
7598                && !nudge_first
7599                && let Some(cur) = class.clone()
7600            {
7601                // The chain ended here (no successor, or a repeated failure).
7602                let h = state
7603                    .seat_history
7604                    .entry(originals[i].seat.key.clone())
7605                    .or_default();
7606                h.failed.insert(specs[i].id.clone());
7607                h.last_fail = Some(cur);
7608            }
7609            let parsed = match out {
7610                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7611                AgentOutcome::Quota(o) => {
7612                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7613                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7614                }
7615                // Not a parseable answer: the nudge loop re-asks it, which is
7616                // exactly what a dropped stream needs. Just don't hand its raw
7617                // error JSON to `extract_json`.
7618                AgentOutcome::Dropped(o) => {
7619                    let why = o
7620                        .dropped
7621                        .as_ref()
7622                        .map(|d| d.why.as_str())
7623                        .unwrap_or("the CLI ended the stream without delivering its answer");
7624                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7625                }
7626                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7627            };
7628            let quota = class == Some(FailClass::Quota);
7629            let failed = parsed.is_err();
7630            done[i] = Some(parsed);
7631            if carry && !failed {
7632                let h = state
7633                    .seat_history
7634                    .entry(originals[i].seat.key.clone())
7635                    .or_default();
7636                h.failed.remove(&specs[i].id);
7637                h.last_ok = Some(specs[i].id.clone());
7638                h.last_fail = None;
7639            }
7640            // Do not re-ask a rate-limited seat (quota) — a retry is known to
7641            // fail the same way; and never re-ask a seat that already parsed.
7642            // A failed agent that still has a successor is not re-asked
7643            // either: the handover was its remedy and has just been refused
7644            // (the chain stops on a repeated failure class). A seat with no
7645            // successor left (a single-agent roster, the roster's tail, or an
7646            // empty roster) keeps the same-agent nudge, bounded by `retries`.
7647            let agent_failure = class.is_some()
7648                && !nudge_first
7649                && !roster.is_empty()
7650                && next_agent(i, &tried[i], &specs).is_some();
7651            if failed && !quota && !agent_failure && nudges[i] < retries {
7652                nudges[i] += 1;
7653                still.push(i);
7654            }
7655        }
7656        pending = still;
7657    }
7658
7659    // One loss per seat whose chain ended on a quota: the intermediate ones
7660    // were absorbed by a handover and are not losses.
7661    for (i, q) in last_quota.into_iter().enumerate() {
7662        if let Some(reset) = q {
7663            losses.push(QuotaLoss {
7664                seat: originals[i].seat.key.clone(),
7665                node: ctx.node.to_owned(),
7666                at: Timestamp::now(),
7667                reset,
7668            });
7669        }
7670    }
7671
7672    seats
7673        .into_iter()
7674        .zip(done)
7675        .zip(nudges)
7676        .map(|((seat, res), attempts)| {
7677            (
7678                seat,
7679                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7680                attempts,
7681            )
7682        })
7683        .collect()
7684}
7685
7686/// Acquire the shared build cache's lease, waiting out contention within
7687/// `budget` (never past it — see AGENTS.md's build-cache section on why an
7688/// unbounded wait is never acceptable).
7689///
7690/// A first, non-blocking check happens before ever waiting; if it finds the
7691/// lease busy, that fact is logged as a `verify` event *and* flushed with
7692/// [`RunState::save`] immediately — not only once the wait finally succeeds
7693/// or gives up — so a `magi show` run by a different process while this one
7694/// is still waiting reads a `run.json` that says so, rather than whatever it
7695/// looked like before the wait started. The same applies to the terminal
7696/// failure: logged and saved before this returns `Err`, so a caller that
7697/// could not get the lease at all still leaves a legible record of why.
7698async fn acquire_cache_lease(
7699    state: &mut RunState,
7700    cache_dir: &Path,
7701    owner: &crate::cache::Owner,
7702    budget: Duration,
7703    context: &str,
7704) -> Result<crate::cache::Guard> {
7705    let home = crate::run::home();
7706    let started = Instant::now();
7707    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7708        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7709        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7710        Err(e) => {
7711            state.event(
7712                "verify",
7713                format!("{context}: could not check the shared build cache: {e:#}"),
7714            );
7715            if let Err(e2) = state.save() {
7716                tracing::warn!("could not persist a cache-check failure: {e2:#}");
7717            }
7718            return Err(e);
7719        }
7720    };
7721    state.event(
7722        "verify",
7723        format!(
7724            "{context}: waiting for the shared build cache at {} ({})",
7725            cache_dir.display(),
7726            busy.describe()
7727        ),
7728    );
7729    if let Err(e) = state.save() {
7730        tracing::warn!("could not persist a cache wait: {e:#}");
7731    }
7732    let remaining = budget.saturating_sub(started.elapsed());
7733    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7734        Ok(g) => Ok(g),
7735        Err(e) => {
7736            state.event("verify", format!("{context}: {e:#}"));
7737            if let Err(e2) = state.save() {
7738                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7739            }
7740            Err(e)
7741        }
7742    }
7743}
7744
7745/// Run `body` — a verify command batch — while holding the shared build
7746/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
7747/// never interleave with another borrower's build against the same
7748/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
7749/// timeout, or a human's own `magi review`. See the `cache` module doc for
7750/// why this matters more than Cargo's own per-target locking covers — two
7751/// *different* worktrees building the same package name/version into one
7752/// cache directory is a staleness bug, not a lock contention one.
7753///
7754/// The wait for the lease is carved out of `budget`, never on top of it —
7755/// `body` is handed whatever is left, so a caller's own node timeout is the
7756/// only clock involved, exactly what AGENTS.md's build-cache section asks
7757/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
7758/// cache configured at all — this is a pass-through: `body` runs with the
7759/// full budget and nothing is leased.
7760///
7761/// A lease that cannot be acquired within `budget` is reported as a single
7762/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
7763/// verification — the same shape a spawn failure already takes in
7764/// [`run_commands`], so a caller need not special-case it.
7765#[allow(clippy::too_many_arguments)]
7766async fn with_cache_lease<'s, F, Fut>(
7767    state: &'s mut RunState,
7768    cache_dir: Option<&Path>,
7769    node: &str,
7770    seat: &str,
7771    worktree: &Path,
7772    head: &str,
7773    budget: Duration,
7774    context: &str,
7775    body: F,
7776) -> (Vec<CommandOutcome>, bool)
7777where
7778    F: FnOnce(&'s mut RunState, Duration) -> Fut,
7779    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7780{
7781    let Some(cache_dir) = cache_dir else {
7782        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7783        return (outcomes, retried);
7784    };
7785    let home = crate::run::home();
7786    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7787    let started = Instant::now();
7788    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7789        Ok(g) => g,
7790        Err(e) => {
7791            return (
7792                vec![CommandOutcome {
7793                    command: "(waiting for the shared build cache)".to_owned(),
7794                    code: None,
7795                    output_tail: e.to_string(),
7796                    duration_ms: started.elapsed().as_millis() as u64,
7797                    resource_blocked: true,
7798                }],
7799                false,
7800            );
7801        }
7802    };
7803    let identity = crate::cache::Identity::new(worktree, head);
7804    if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7805        // A failed freshness check means this process cannot vouch for what
7806        // is sitting in the cache right now - on Windows this is exactly the
7807        // "a stale test executable is still locked, `cargo clean -p` cannot
7808        // remove it" case the evidence log records. Running verify anyway
7809        // and reporting whatever it says would let a result nobody can trust
7810        // stand for the tree it claims to have checked; fail the step
7811        // instead of the patch.
7812        state.event(
7813            "verify",
7814            format!(
7815                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7816                worktree.display(),
7817                short(head)
7818            ),
7819        );
7820        guard.release();
7821        return (
7822            vec![CommandOutcome {
7823                command: "(confirming the shared build cache is fresh)".to_owned(),
7824                code: None,
7825                output_tail: e.to_string(),
7826                duration_ms: started.elapsed().as_millis() as u64,
7827                resource_blocked: true,
7828            }],
7829            false,
7830        );
7831    }
7832    let remaining = budget.saturating_sub(started.elapsed());
7833    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7834    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
7835    // `start_kill`); confirm it actually has before handing the directory to
7836    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
7837    // for what this can and cannot see.
7838    if !timed_out_pids.is_empty() {
7839        wait_for_timed_out_children_to_die(&timed_out_pids).await;
7840    }
7841    guard.release();
7842    (outcomes, retried)
7843}
7844
7845/// Poll `pids` — commands [`run_commands`] reports as still running when its
7846/// own timeout elapsed — until every one is confirmed gone, or
7847/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
7848///
7849/// Real confirmation where confirmation is possible, not a substitute for
7850/// full process-tree observation: a grandchild the timed-out process spawned
7851/// and that survives independently of it is invisible to a pid check the
7852/// same way it always was, and continuing to observe and collect *that*
7853/// stays a different piece of work with its own owner. This only narrows a
7854/// fixed blind wait into an actual check of the pids this process does know
7855/// about.
7856async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7857    wait_for_pids_with(
7858        pids,
7859        crate::proc::pid_alive,
7860        LEASE_RELEASE_POLL,
7861        LEASE_RELEASE_MAX_WAIT,
7862    )
7863    .await;
7864}
7865
7866/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
7867/// interval and ceiling supplied by the caller, so the polling *logic* -
7868/// returns as soon as every pid reports dead, gives up at the ceiling
7869/// otherwise - is testable on millisecond durations without asking the real
7870/// OS about a pid at all.
7871async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7872    pids: &[u32],
7873    alive: F,
7874    poll: Duration,
7875    max_wait: Duration,
7876) {
7877    let deadline = Instant::now() + max_wait;
7878    loop {
7879        if pids.iter().all(|&pid| !alive(pid)) {
7880            return;
7881        }
7882        if Instant::now() >= deadline {
7883            return;
7884        }
7885        tokio::time::sleep(poll).await;
7886    }
7887}
7888
7889/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
7890/// admission that it could not even get a verify command to run, as opposed
7891/// to evidence the command actually produced? A caller that would otherwise
7892/// read a resource-blocked outcome as a red command must check this first:
7893/// see [`Runner::gate`], which retries rather than records `Blocked` when
7894/// this is true.
7895fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7896    outcomes.iter().any(|o| o.resource_blocked)
7897}
7898
7899/// What [`Runner::gate_fix_round`] decided.
7900enum GateFix {
7901    /// The tree changed and `verify.e2e` is still green: run the gate again.
7902    Retry,
7903    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
7904    /// last failure stands and the run ends blocked.
7905    Stop,
7906    /// `verify.e2e` could not run after the fix (magi's own contention):
7907    /// decide nothing now, a later reentry retries.
7908    Defer,
7909}
7910
7911/// Is every red command in `outcomes` an ordinary failure the code could
7912/// explain: it ran, exited non-zero, and said something?
7913///
7914/// A timeout, a spawn failure and a killed process all leave `code` `None`;
7915/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
7916/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
7917/// what the command is stays the gate's business.
7918fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7919    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7920    red.peek().is_some()
7921        && red.all(|o| {
7922            !o.resource_blocked
7923                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7924                && !o.output_tail.trim().is_empty()
7925        })
7926}
7927
7928/// Describe one verify command's outcome for the event log, distinguishing a
7929/// build/link failure — the toolchain never produced a binary to run — from
7930/// an actual test failure, since only the latter is a verdict on the patch.
7931fn e2e_outcome_label(o: &CommandOutcome) -> String {
7932    if o.ok() {
7933        return "pass".to_owned();
7934    }
7935    let reason = if o.build_failed() {
7936        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7937    } else {
7938        format!("FAIL ({:?})", o.code)
7939    };
7940    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7941}
7942
7943/// Run `verify.e2e`, retrying once if the first attempt could not build or
7944/// link — a build/link failure is frequently a race against a shared
7945/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
7946/// `verify` event per command, tagged with `context` (normally `"round N"`)
7947/// so the two call sites that need this — the ordinary per-round leg in
7948/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
7949/// it will ever call a round green — read identically in the event log.
7950async fn run_e2e_with_retry(
7951    state: &mut RunState,
7952    shell: &[String],
7953    commands: &[String],
7954    worktree: &Path,
7955    timeout: Duration,
7956    context: &str,
7957) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7958    let (mut e2e, mut timed_out_pids) = run_commands(
7959        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7960    )
7961    .await;
7962    for o in &e2e {
7963        state.event(
7964            "verify",
7965            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7966        );
7967    }
7968    // A build/link failure is not a verdict on the patch — it is frequently a
7969    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
7970    // one retry before letting a red like that decide the round.
7971    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7972    if verify_retried {
7973        state.event(
7974            "verify",
7975            format!(
7976                "{context}: verify could not build/link, not a test result — retrying once \
7977                 before concluding"
7978            ),
7979        );
7980        let retried = run_commands(
7981            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7982        )
7983        .await;
7984        e2e = retried.0;
7985        // Both attempts' timeouts matter, not just the last one: the first
7986        // attempt's descendants may still be alive alongside the retry's.
7987        timed_out_pids.extend(retried.1);
7988        for o in &e2e {
7989            state.event(
7990                "verify",
7991                format!(
7992                    "{context}: retry `{}` -> {}",
7993                    o.command,
7994                    e2e_outcome_label(o)
7995                ),
7996            );
7997        }
7998    }
7999    (e2e, verify_retried, timed_out_pids)
8000}
8001
8002/// Run configured shell commands in `cwd`, in order. The second element is
8003/// the pid of every command that hit `timeout` and was still running when
8004/// this stopped waiting on it (best-effort: `None` when the platform did not
8005/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
8006/// that releases a shared resource afterward needs to know.
8007///
8008/// Records `task` into [`RunState::active`] at every command boundary
8009/// (`RunState::task_command`) and clears it once the whole list has run
8010/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
8011/// for minutes with no seat and no output of its own to show for it (see
8012/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
8013/// apart from "not yet run" without this), and this is the only place that
8014/// knows which command is running right now and how many are left. Three
8015/// saves per command — start, not per second — matching the same "only at a
8016/// boundary" rule [`wave`] already follows for seats.
8017#[allow(clippy::too_many_arguments)]
8018async fn run_commands(
8019    state: &mut RunState,
8020    node: &str,
8021    task: &str,
8022    attempt: usize,
8023    shell: &[String],
8024    commands: &[String],
8025    cwd: &Path,
8026    timeout: Duration,
8027) -> (Vec<CommandOutcome>, Vec<u32>) {
8028    if commands.is_empty() {
8029        // Nothing to mark as running and nothing to clear — an empty list
8030        // means "not configured", and touching `active` (or the disk) over
8031        // that would be a write for every round of a repo with no
8032        // `verify.e2e` / `verify.gate` commands at all.
8033        return (Vec::new(), Vec::new());
8034    }
8035    let mut out = Vec::new();
8036    let mut timed_out_pids = Vec::new();
8037    let total = commands.len();
8038    for (idx, command) in commands.iter().enumerate() {
8039        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8040        if let Err(e) = state.save() {
8041            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8042        }
8043        let started = Instant::now();
8044        let mut cmd = tokio::process::Command::new(&shell[0]);
8045        cmd.quiet();
8046        cmd.args(&shell[1..])
8047            .arg(command)
8048            .current_dir(cwd)
8049            .stdin(std::process::Stdio::null())
8050            .stdout(std::process::Stdio::piped())
8051            .stderr(std::process::Stdio::piped())
8052            .kill_on_drop(true);
8053        let spawned = cmd.spawn();
8054        let (code, body) = match spawned {
8055            Ok(child) => {
8056                // Captured before the child is consumed below: `kill_on_drop`
8057                // only *asks* the process to die when the timeout branch
8058                // drops it, and the pid is the only way anyone downstream can
8059                // later check whether that request actually took.
8060                let pid = child.id();
8061                match tokio::time::timeout(timeout, child.wait_with_output()).await {
8062                    Ok(Ok(o)) => {
8063                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8064                        body.push_str(&String::from_utf8_lossy(&o.stderr));
8065                        (o.status.code(), body)
8066                    }
8067                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
8068                    Err(_) => {
8069                        if let Some(pid) = pid {
8070                            timed_out_pids.push(pid);
8071                        }
8072                        (None, format!("timed out after {}s", timeout.as_secs()))
8073                    }
8074                }
8075            }
8076            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8077        };
8078        out.push(CommandOutcome {
8079            command: command.clone(),
8080            code,
8081            output_tail: tail(&body, OUTPUT_TAIL),
8082            duration_ms: started.elapsed().as_millis() as u64,
8083            resource_blocked: false,
8084        });
8085    }
8086    state.task_finished(task);
8087    if let Err(e) = state.save() {
8088        tracing::warn!("could not persist the end of {task}: {e:#}");
8089    }
8090    (out, timed_out_pids)
8091}
8092
8093/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
8094/// section (`report::run`) and in the `merge` event this node records — for
8095/// the operator to run by hand.
8096///
8097/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
8098/// branch whose ruleset forbids merge commits (GitHub's "must not contain
8099/// merge commits", or "require linear history") rejects the push a `--no-ff`
8100/// merge would produce, which is exactly the guidance this function replaces.
8101/// `message`'s first line becomes the squash commit's subject, matching the
8102/// note `report::run` prints alongside this command — see that function for
8103/// why an explicit subject is not optional there.
8104fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8105    let repo = repo.display();
8106    match style {
8107        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8108        MergeStyle::Squash => {
8109            // The subject sits inside double quotes, and a title an agent
8110            // wrote may carry the characters that break out of them.
8111            let subject = message
8112                .lines()
8113                .next()
8114                .unwrap_or(branch)
8115                .replace(['\\', '"', '$', '`'], "");
8116            format!(
8117                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8118            )
8119        }
8120        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8121    }
8122}
8123
8124/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
8125/// under the hood, rejects a `title` over 256 characters and the whole
8126/// command fails — no PR at all, for a run whose body was otherwise fine
8127/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
8128/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
8129/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
8130/// this bound. It is a margin, not a guarantee — a title packed
8131/// with multi-unit characters could still in principle land close to the
8132/// edge, but a real task title's occasional emoji or accented letter fits
8133/// comfortably inside it.
8134const PR_TITLE_MAX: usize = 240;
8135
8136/// A pull request title from the opening line of `text`, or `None` when that
8137/// line is not English (GitHub text is) or has no letters.
8138///
8139/// A line within `max` is kept as is. A longer one is cut at the end of its
8140/// first sentence when that falls inside `max`, else at a word boundary with a
8141/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
8142/// make every merely-truncated title look non-English). The language check
8143/// runs on the kept text before any mark is added, so only what GitHub will
8144/// show is judged: an English opening followed by non-ASCII far past the cut
8145/// still passes.
8146fn english_title(text: &str, max: usize) -> Option<String> {
8147    let line = queue::first_line(text)?;
8148    let chars: Vec<char> = line.chars().collect();
8149    let (kept, mark) = if chars.len() <= max {
8150        (line.to_owned(), "")
8151    } else if let Some(end) = sentence_end(&chars, max) {
8152        (chars[..end].iter().collect::<String>(), "")
8153    } else {
8154        let room = max.saturating_sub(3);
8155        // Cut at the last space inside the room; when the char just past the
8156        // room is a space the room already ends on a word.
8157        let cut = if chars[room].is_whitespace() {
8158            room
8159        } else {
8160            chars[..room]
8161                .iter()
8162                .rposition(|c| c.is_whitespace())
8163                .unwrap_or(room)
8164        };
8165        let head: String = chars[..cut].iter().collect();
8166        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8167        (head.to_owned(), "...")
8168    };
8169    if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8170        return None;
8171    }
8172    Some(format!("{kept}{mark}"))
8173}
8174
8175/// The char length of the first sentence of `chars` when it ends within `max`
8176/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
8177/// abbreviations so `e.g. foo` does not end a title early.
8178fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8179    const MIN: usize = 20;
8180    for i in MIN..max.min(chars.len()) {
8181        if !matches!(chars[i], '.' | '!' | '?') {
8182            continue;
8183        }
8184        let Some(&next) = chars.get(i + 1) else {
8185            continue;
8186        };
8187        if !next.is_whitespace() {
8188            continue;
8189        }
8190        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8191        if after.is_some_and(|c| c.is_ascii_lowercase()) {
8192            continue;
8193        }
8194        let word: String = chars[..i]
8195            .iter()
8196            .rev()
8197            .take_while(|c| !c.is_whitespace())
8198            .collect::<Vec<_>>()
8199            .into_iter()
8200            .rev()
8201            .collect();
8202        let word = word.to_ascii_lowercase();
8203        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8204            continue;
8205        }
8206        let end = chars[..i]
8207            .iter()
8208            .rposition(|c| !c.is_whitespace())
8209            .map_or(i, |p| p + 1);
8210        return Some(end);
8211    }
8212    None
8213}
8214
8215/// What `merge = "pr"` (and the merge commit of the other modes) says about a
8216/// change: a title and a body describing what was *implemented*, not the task
8217/// that asked for it. A task reads as a request; a reader of the merged
8218/// history wants the change.
8219struct PrMessage {
8220    title: String,
8221    body: String,
8222}
8223
8224impl PrMessage {
8225    /// Title, blank line, body. The first line is the squash/merge commit
8226    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
8227    /// has to stay one sensible line.
8228    fn commit_message(&self) -> String {
8229        format!("{}\n\n{}", self.title, self.body)
8230    }
8231}
8232
8233/// The text after a leading `TITLE:` (any case) on `line`.
8234fn title_marker(line: &str) -> Option<&str> {
8235    let line = line.trim();
8236    let head = line.get(..6)?;
8237    head.eq_ignore_ascii_case("title:")
8238        .then(|| line[6..].trim())
8239}
8240
8241/// The implementer's own one-line title: the `TITLE:` line the implement
8242/// prompt asks for at the top of its SUMMARY. Candidate commits are all
8243/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
8244/// source, and a title that says as much is refused here too.
8245fn summary_title(summary: &str) -> Option<String> {
8246    let first = summary.lines().find(|l| !l.trim().is_empty())?;
8247    let raw = title_marker(first)?;
8248    if raw.is_empty() {
8249        return None;
8250    }
8251    let title = queue::title_from(raw, PR_TITLE_MAX);
8252    let lower = title.to_ascii_lowercase();
8253    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8254        return None;
8255    }
8256    Some(title)
8257}
8258
8259/// How `open_review`'s instruction begins; see [`landing_title`].
8260const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8261
8262/// Marker `open_review` gives a candidate that nothing in the roster wrote.
8263const EXISTING_BRANCH: &str = "(existing branch)";
8264
8265/// Does this run review work that already existed, rather than implement a
8266/// task? Runs recorded before `reviewed_commits` existed carry only the
8267/// candidate marker.
8268fn is_review_run(state: &RunState) -> bool {
8269    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8270}
8271
8272/// The title of a review-only run: the subject of the oldest commit under
8273/// review. Later commits are usually fixups, and `instruction` is the review
8274/// prompt, which says nothing about the change. GitHub text is English, so a
8275/// non-ASCII or blank subject yields `None` and the caller's neutral title.
8276fn review_title(state: &RunState) -> Option<String> {
8277    english_subject(state.reviewed_commits.as_ref()?.first()?)
8278}
8279
8280/// `raw` as a pull request title, or `None` when it is blank, not English
8281/// (GitHub text is), or one of magi's own candidate commit subjects.
8282fn english_subject(raw: &str) -> Option<String> {
8283    let raw = raw.trim();
8284    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8285        return None;
8286    }
8287    let title = queue::title_from(raw, PR_TITLE_MAX);
8288    let lower = title.to_ascii_lowercase();
8289    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8290        return None;
8291    }
8292    Some(title)
8293}
8294
8295/// What a review-only run's branch says about itself, read at the moment the
8296/// pull request is opened.
8297#[derive(Debug, Clone, PartialEq, Eq)]
8298struct BranchFacts {
8299    /// `(subject, body)` of each commit, oldest first.
8300    commits: Vec<(String, String)>,
8301    /// Trimmed `git diff --stat`.
8302    stat: String,
8303}
8304
8305/// Longest diff stat shown: this many file lines plus the summary line.
8306const STAT_FILE_LINES: usize = 25;
8307/// Cap on the commit list, well inside GitHub's 65536-character body limit.
8308const COMMITS_MAX_CHARS: usize = 20_000;
8309
8310/// Read the commits and diff stat of `base..branch`. `None` when git cannot
8311/// say or finds nothing, so the caller falls back to what the run recorded.
8312async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8313    let commits = git::commit_log(repo, base, branch).await.ok()?;
8314    if commits.is_empty() {
8315        return None;
8316    }
8317    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8318    let lines: Vec<&str> = stat.lines().collect();
8319    let stat = if lines.len() > STAT_FILE_LINES + 1 {
8320        let omitted = lines.len() - 1 - STAT_FILE_LINES;
8321        let more = format!(" ... {omitted} more file(s)");
8322        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8323        kept.push(&more);
8324        kept.push(lines[lines.len() - 1]);
8325        kept.join("\n")
8326    } else {
8327        lines.join("\n")
8328    };
8329    Some(BranchFacts { commits, stat })
8330}
8331
8332/// Defang what would break the surrounding markdown: a closing `</details>`
8333/// and a code fence.
8334fn markdown_safe(text: &str) -> String {
8335    text.replace("</details>", "&lt;/details&gt;")
8336        .replace("\x60\x60\x60", "~~~")
8337}
8338
8339fn neutral_title(state: &RunState, winner: char) -> String {
8340    format!(
8341        "chore: land candidate {} of run {}",
8342        winner.to_ascii_uppercase(),
8343        state.id
8344    )
8345}
8346
8347/// The pull request title to hand to `land::merge_subject`. A review-only run
8348/// opened by an earlier build titled its pull request with the review prompt;
8349/// that title is dropped (empty, so the fallback applies) rather than landed.
8350/// Any other title, including an operator's rename, passes through untouched,
8351/// and so does every title of a run that implements a task.
8352pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8353    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8354        ""
8355    } else {
8356        pr_title
8357    }
8358}
8359
8360/// What the squash subject falls back to when the pull request title is empty
8361/// or candidate-shaped: for a review-only run the derived title, never the
8362/// review prompt held in `instruction`.
8363pub fn landing_subject_source(state: &RunState) -> String {
8364    if is_review_run(state) {
8365        let winner = state.candidates.first().map_or('A', |c| c.label);
8366        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8367    }
8368    state.instruction.clone()
8369}
8370
8371/// `summary` without its `TITLE:` line, which the pull request title already
8372/// carries.
8373fn summary_without_title(summary: &str) -> String {
8374    let mut lines = summary.trim().lines().peekable();
8375    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8376        lines.next();
8377    }
8378    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8379}
8380
8381/// The pull request title and body for the winning candidate.
8382///
8383/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
8384/// the task's own opening line via [`queue::title_from`] when there is none.
8385/// `state.instruction` can open with blank lines (`task_text` only rejects a
8386/// body that is blank *entirely*), which `title_from` skips.
8387///
8388/// Body: the implementer's summary and the fixer's notes, then — when the
8389/// winning review round was not clean — the findings still open and whatever
8390/// the fixer declined, so `merge = "pr"` hands the reader the same material
8391/// `magi show` does. The task follows inside a collapsed block, and the
8392/// footer repeats the run and candidate as plain tags for a reader holding
8393/// only the merged commit or the PR body.
8394#[cfg(test)]
8395fn pr_message(state: &RunState, winner: char) -> PrMessage {
8396    pr_message_with(state, winner, None)
8397}
8398
8399/// [`pr_message`] with what the branch of a review-only run says about itself.
8400/// `facts` is ignored for a run that implements a task.
8401#[cfg(test)]
8402fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8403    let raw = pr_message_raw(state, winner, facts);
8404    let id = crate::scrub::Identity::current();
8405    PrMessage {
8406        title: crate::scrub::scrub(&raw.title, &id),
8407        body: crate::scrub::scrub(&raw.body, &id),
8408    }
8409}
8410
8411fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8412    let summary = state
8413        .candidates
8414        .iter()
8415        .find(|c| c.label == winner)
8416        .map(|c| c.summary.as_str())
8417        .unwrap_or_default();
8418    // The fallback is the operator's own words and may not be English; GitHub
8419    // text always is, so a non-English task gets a neutral title instead.
8420    let review = is_review_run(state);
8421    let title = if review {
8422        facts
8423            .and_then(|f| english_subject(&f.commits.first()?.0))
8424            .or_else(|| review_title(state))
8425            .or_else(|| {
8426                state
8427                    .candidates
8428                    .iter()
8429                    .find(|c| c.label == winner)
8430                    .filter(|c| !c.branch.starts_with("magi/"))
8431                    .and_then(|c| english_subject(&c.branch))
8432            })
8433            .unwrap_or_else(|| neutral_title(state, winner))
8434    } else {
8435        summary_title(summary).unwrap_or_else(|| {
8436            english_title(&state.instruction, PR_TITLE_MAX)
8437                .unwrap_or_else(|| neutral_title(state, winner))
8438        })
8439    };
8440
8441    let mut body = String::new();
8442    let what = summary_without_title(summary);
8443    if !what.is_empty() {
8444        body.push_str("## Summary\n\n");
8445        body.push_str(&what);
8446        body.push_str("\n\n");
8447    }
8448
8449    // The last round is usually a clean verification pass with no fix of its
8450    // own, so every round's notes are read, not just the final one's.
8451    let notes: Vec<(usize, &str)> = state
8452        .reviews
8453        .iter()
8454        .filter_map(|r| {
8455            let n = r.fix.as_ref()?.notes.trim();
8456            (!n.is_empty()).then_some((r.round, n))
8457        })
8458        .collect();
8459    if !notes.is_empty() {
8460        body.push_str("## Review fixes\n\n");
8461        if let [(_, only)] = notes.as_slice() {
8462            body.push_str(only);
8463            body.push_str("\n\n");
8464        } else {
8465            for (round, n) in &notes {
8466                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8467            }
8468        }
8469    }
8470    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8471
8472    let open = state.open_findings();
8473    if !open.is_empty() {
8474        body.push_str("## Open review findings\n\n");
8475        for f in &open {
8476            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8477        }
8478        body.push('\n');
8479    }
8480
8481    if let Some(fix) = fix
8482        && !fix.rejected.is_empty()
8483    {
8484        body.push_str("## Declined by the fixer\n\n");
8485        for r in &fix.rejected {
8486            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8487        }
8488        body.push('\n');
8489    }
8490
8491    if review {
8492        // The review prompt is not the task; list what the branch carries.
8493        body.push_str("## Commits under review\n\n");
8494        if let Some(facts) = facts {
8495            let mut left = COMMITS_MAX_CHARS;
8496            for (i, (subject, text)) in facts.commits.iter().enumerate() {
8497                let mut entry = format!("- {}\n", markdown_safe(subject));
8498                for l in markdown_safe(text).lines() {
8499                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
8500                }
8501                if left == 0 {
8502                    body.push_str(&format!(
8503                        "- ... {} more commit(s)\n",
8504                        facts.commits.len() - i
8505                    ));
8506                    break;
8507                }
8508                if entry.len() > left {
8509                    // Even the first commit is cut: one huge body must not
8510                    // push the whole description past GitHub's limit.
8511                    let mut end = left;
8512                    while !entry.is_char_boundary(end) {
8513                        end -= 1;
8514                    }
8515                    entry.truncate(end);
8516                    entry.push_str("\n  ... (truncated)\n");
8517                    left = 0;
8518                } else {
8519                    left -= entry.len();
8520                }
8521                body.push_str(&entry);
8522            }
8523            if !facts.stat.trim().is_empty() {
8524                body.push_str(&format!(
8525                    "\n## Diff stat\n\n```\n{}\n```\n",
8526                    markdown_safe(facts.stat.trim())
8527                ));
8528            }
8529        } else {
8530            match &state.reviewed_commits {
8531                Some(subjects) => {
8532                    for s in subjects {
8533                        body.push_str(&format!("- {}\n", s.trim()));
8534                    }
8535                }
8536                None => {
8537                    // An older run kept only the prompt, with the commit list
8538                    // after its first paragraph.
8539                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8540                    body.push_str(rest.trim());
8541                    body.push('\n');
8542                }
8543            }
8544        }
8545    } else {
8546        let task = state.instruction.trim();
8547        let task = if task.is_empty() {
8548            "(empty task)"
8549        } else {
8550            task
8551        };
8552        body.push_str(&format!(
8553            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8554            task.replace("</details>", "&lt;/details&gt;")
8555        ));
8556    }
8557
8558    body.push_str(&format!(
8559        "\n---\nmagi:run/{} magi:candidate-{}\n",
8560        state.id,
8561        winner.to_ascii_lowercase()
8562    ));
8563
8564    PrMessage { title, body }
8565}
8566
8567/// The task with what the repository says about the existing work it names
8568/// appended, so an implementer knows what it started from and what it must
8569/// not redo. Unchanged when the task names nothing.
8570fn seeded_instruction(state: &RunState) -> String {
8571    match refs::describe(&state.seeds) {
8572        Some(facts) => format!(
8573            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8574             Candidates start from the unmerged branch named above, when there \
8575             is one, and carry any unmerged commit named by sha as a \
8576             cherry-pick. Check that this is what the task meant before \
8577             building on it.",
8578            state.instruction
8579        ),
8580        None => state.instruction.clone(),
8581    }
8582}
8583
8584/// Does the winner have no commits ahead of the base it would land on?
8585/// Any failure to find out reads as "not empty": the merge then behaves as it
8586/// always did rather than refusing on a guess.
8587async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8588    let base = &state.base_branch;
8589    let mut against = base.clone();
8590    if mode == MergeMode::Pr {
8591        // A pull request lands on the remote's base, never the local branch
8592        // of the same name: if that cannot be read, "not empty" is the safe
8593        // answer.
8594        let remote = &state.config.merge.remote;
8595        let tracking = format!("{remote}/{base}");
8596        let fetched = git::fetch(repo, remote, base).await;
8597        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8598            against = tracking;
8599        } else {
8600            return false;
8601        }
8602    }
8603    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8604}
8605
8606/// Why nothing was opened for an empty winner, with what the task's own
8607/// references resolved to.
8608fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8609    let mut detail = format!(
8610        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8611         nothing to open a pull request for"
8612    );
8613    match refs::describe(&state.seeds) {
8614        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8615        None => detail.push_str(
8616            "\nThe task names no existing branch or commit; if it means to land work \
8617             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8618        ),
8619    }
8620    detail
8621}
8622
8623/// What the `Pr` merge does once it knows whether the branch already has an
8624/// open pull request.
8625#[derive(Debug, PartialEq, Eq)]
8626enum PrPlan {
8627    Create,
8628    Adopt { url: String, title: String },
8629    Stop(String),
8630}
8631
8632/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
8633/// a failed lookup -> stop with the real reason. Never guesses.
8634fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8635    match found {
8636        Ok(land::OpenPr::None) => PrPlan::Create,
8637        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8638        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8639            "several open pull requests exist for this branch, not picking one: {}",
8640            urls.join(" ")
8641        )),
8642        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8643    }
8644}
8645
8646/// `gh pr create`, returning the PR url.
8647async fn gh_pr_create(
8648    cwd: &Path,
8649    base: &str,
8650    head: &str,
8651    title: &str,
8652    body: &str,
8653) -> Result<String> {
8654    let out = tokio::process::Command::new("gh")
8655        .args([
8656            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8657        ])
8658        .current_dir(cwd)
8659        .quiet()
8660        .stdin(std::process::Stdio::null())
8661        .output()
8662        .await
8663        .context("spawn gh")?;
8664    if out.status.success() {
8665        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8666    } else {
8667        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8668    }
8669}
8670
8671/// Tear a run's worktrees and branches down.
8672///
8673/// `home` is where the updated `run.json` is saved (via
8674/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
8675/// a housekeeping pass already has its own honest `home` handed to it, and
8676/// falling through to the global here would write back through whichever
8677/// directory some other process or test pinned into that `OnceLock` first,
8678/// not the one the caller actually resolved its `runs` and `state` from.
8679pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8680    let repo = state.repo.clone();
8681    let root = state.worktree_root();
8682    let winner = state.tally.as_ref().map(|t| t.winner);
8683    let mut removed = Vec::new();
8684
8685    for i in 0..state.candidates.len() {
8686        let c = state.candidates[i].clone();
8687        let is_winner = Some(c.label) == winner;
8688        if is_winner && !drop_winner {
8689            continue;
8690        }
8691        if c.worktree.exists() {
8692            git::worktree_remove(&repo, &c.worktree).await.ok();
8693            removed.push(c.worktree.to_string_lossy().into_owned());
8694        }
8695        // A branch handed to a later run (and its pull request) is not this
8696        // run's to delete.
8697        let handed_over = state.released_branches.contains(&c.branch);
8698        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8699            git::branch_delete(&repo, &c.branch).await.ok();
8700            removed.push(c.branch.clone());
8701        }
8702        state.candidates[i].folded = true;
8703    }
8704
8705    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8706        let path = name.path();
8707        let keep = !drop_winner
8708            && winner.is_some_and(|w| {
8709                path.file_name()
8710                    .is_some_and(|n| n == format!("cand-{w}").as_str())
8711            });
8712        if keep {
8713            continue;
8714        }
8715        git::worktree_remove(&repo, &path).await.ok();
8716        removed.push(path.to_string_lossy().into_owned());
8717    }
8718
8719    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
8720    // judge worktrees, so once the loop above has cleared all of them out,
8721    // the parent is a bare directory nobody else was ever going to remove -
8722    // git only ever managed what was inside it. Left alone, one of these
8723    // accumulates per fully-folded run; the operator's own machine had 74.
8724    // `remove_if_empty` re-checks rather than assuming: a run whose winner
8725    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
8726    // so does anything a run never claimed that happens to share the bay.
8727    remove_if_empty(&root);
8728
8729    if state.enabled_worktree_config && drop_winner {
8730        // A release, not a raw disable: some sibling run in this repository
8731        // may still hold its own reference (see `git::acquire_worktree_config`),
8732        // and only the last release actually turns the setting back off.
8733        git::release_worktree_config(&repo).await.ok();
8734        state.enabled_worktree_config = false;
8735    }
8736    state.save_under(home)?;
8737    Ok(removed)
8738}
8739
8740/// Remove `dir` if it exists and has nothing in it.
8741///
8742/// Best-effort and silent by design: a directory that is not empty (a run
8743/// whose winner is still parked there, a stray file some other process left)
8744/// is exactly the case this must refuse, and a directory that is already gone
8745/// is not a failure worth reporting either. `std::fs::remove_dir` itself
8746/// already refuses a non-empty directory, so the emptiness check below is
8747/// belt, not suspenders - it is what keeps this from ever attempting the
8748/// removal in the case that matters, rather than trusting `remove_dir`'s
8749/// error path to have no side effects if it ever changed.
8750fn remove_if_empty(dir: &Path) {
8751    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8752        std::fs::remove_dir(dir).ok();
8753    }
8754}
8755
8756/// Severity of the worst open finding in the last review round, for reporting.
8757pub fn worst_open(state: &RunState) -> Option<Severity> {
8758    state
8759        .reviews
8760        .last()?
8761        .reviews
8762        .iter()
8763        .flat_map(|r| r.findings.iter())
8764        .map(|f| f.severity)
8765        .max()
8766}
8767
8768/// `[worktree] setup` hides its products per worktree, which needs
8769/// `extensions.worktreeConfig`. Taken once per run and released with the run's
8770/// worktrees, like the hook's; every place that runs setup calls this first.
8771async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
8772    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
8773        git::acquire_worktree_config(repo).await?;
8774        state.enabled_worktree_config = true;
8775    }
8776    Ok(())
8777}
8778
8779#[cfg(test)]
8780mod tests {
8781    #[test]
8782    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8783        let leaked = vec!["chore(deps): update a crate".to_owned()];
8784        let own = "fix(daemon): apply a chosen action";
8785        assert!(should_retitle("", own, &leaked));
8786        assert!(should_retitle(
8787            &format!("{REVIEW_PROMPT_OPENING} `x`"),
8788            own,
8789            &leaked
8790        ));
8791        assert!(should_retitle(
8792            "chore: land candidate A of run 1",
8793            own,
8794            &leaked
8795        ));
8796        assert!(should_retitle(
8797            "magi: candidate A (uncommitted work)",
8798            own,
8799            &leaked
8800        ));
8801        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8802        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8803        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8804    }
8805
8806    #[test]
8807    fn pr_merge_plan_creates_adopts_or_stops() {
8808        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8809        assert_eq!(
8810            pr_merge_plan(Ok(land::OpenPr::One {
8811                url: "u".into(),
8812                title: "t".into()
8813            })),
8814            PrPlan::Adopt {
8815                url: "u".into(),
8816                title: "t".into()
8817            }
8818        );
8819        let PrPlan::Stop(many) =
8820            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8821        else {
8822            panic!("many must stop");
8823        };
8824        assert!(many.contains('a') && many.contains('b'));
8825        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8826            panic!("a failed lookup must stop");
8827        };
8828        assert!(err.contains("bad token"));
8829    }
8830
8831    use super::*;
8832    use crate::run::GateStatus;
8833    use std::collections::BTreeMap;
8834    use std::time::Duration;
8835
8836    fn conductor() -> AgentSpec {
8837        AgentSpec {
8838            id: "conductor".to_owned(),
8839            kind: crate::config::AgentKind::Command,
8840            model: None,
8841            command: vec!["true".to_owned()],
8842            extra_args: Vec::new(),
8843            env: BTreeMap::new(),
8844            prompt_delivery: None,
8845        }
8846    }
8847
8848    fn spec(id: &str) -> AgentSpec {
8849        AgentSpec {
8850            id: id.to_owned(),
8851            kind: crate::config::AgentKind::Command,
8852            model: None,
8853            command: vec!["true".to_owned()],
8854            extra_args: Vec::new(),
8855            env: BTreeMap::new(),
8856            prompt_delivery: None,
8857        }
8858    }
8859
8860    fn ids(xs: &[&str]) -> BTreeSet<String> {
8861        xs.iter().map(|s| (*s).to_owned()).collect()
8862    }
8863
8864    #[test]
8865    fn pick_successor_skips_an_agent_another_seat_holds() {
8866        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
8867        let roster = [spec("a"), spec("b"), spec("c")];
8868        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8869        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8870    }
8871
8872    #[test]
8873    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
8874        // The other seat started on c but was handed to d; c is free again.
8875        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
8876        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8877        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8878        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
8879        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
8880    }
8881
8882    #[test]
8883    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
8884        let roster = [spec("a"), spec("b")];
8885        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8886        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8887        let carried = ids(&["b"]);
8888        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
8889        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8890    }
8891
8892    #[test]
8893    fn pick_successor_returns_none_without_any_untried_successor() {
8894        let roster = [spec("a"), spec("b")];
8895        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
8896        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
8897        let carried = ids(&["a"]);
8898        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
8899    }
8900
8901    #[test]
8902    fn pick_successor_rescue_avoids_another_seats_occupant() {
8903        // b is a carried failure and free; a is held by the other seat.
8904        let roster = [spec("a"), spec("b"), spec("c")];
8905        let carried = ids(&["b", "c"]);
8906        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
8907        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8908    }
8909
8910    #[test]
8911    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8912        let roster = [spec("a"), spec("b"), spec("c")];
8913        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8914        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8915    }
8916
8917    #[test]
8918    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8919        let roster = [spec("a"), spec("b"), spec("c")];
8920        let failed = ids(&["a", "b", "c"]);
8921        // Rescue looks at the whole roster, once per id, then runs out.
8922        let mut tried = ids(&["b"]);
8923        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8924        assert_eq!(first.id, "a");
8925        tried.insert(first.id.clone());
8926        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8927        assert_eq!(second.id, "c");
8928        tried.insert(second.id.clone());
8929        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8930    }
8931
8932    #[test]
8933    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8934        let roster = [spec("a"), spec("b")];
8935        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8936        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8937    }
8938
8939    #[test]
8940    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8941        let roster = [spec("a"), spec("b"), spec("c")];
8942        let h = SeatHistory {
8943            failed: ids(&["a"]),
8944            last_ok: Some("b".to_owned()),
8945            last_fail: None,
8946        };
8947        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8948        // A last answerer that left the roster, or later failed, is ignored.
8949        let gone = SeatHistory {
8950            last_ok: Some("zzz".to_owned()),
8951            ..h.clone()
8952        };
8953        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8954        let failed = SeatHistory {
8955            failed: ids(&["a", "b"]),
8956            last_ok: Some("b".to_owned()),
8957            last_fail: None,
8958        };
8959        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8960    }
8961
8962    #[test]
8963    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8964        let first = SeatState::new("review-1", "alpha", 7);
8965        let next = handover_seat("review-1", "gamma", 7);
8966        assert_ne!(first.claude_session, next.claude_session);
8967    }
8968
8969    #[test]
8970    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8971        let mut state = state_with_summary("x", "y");
8972        let a = handover_seat("review-1", "beta", state.next_seat_seed());
8973        let b = handover_seat("review-1", "beta", state.next_seat_seed());
8974        assert_ne!(a.claude_session, b.claude_session);
8975    }
8976
8977    #[test]
8978    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8979        let roster = [spec("a"), spec("b")];
8980        let h = SeatHistory {
8981            failed: ids(&["a", "b"]),
8982            ..SeatHistory::default()
8983        };
8984        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8985        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8986        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8987    }
8988
8989    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
8990    // fallback loop depends on to terminate: it must walk forward from the
8991    // seat's own position, never restart at the front of the roster, and it
8992    // must never hand back an id already tried, however many times that id
8993    // happens to appear.
8994
8995    #[test]
8996    fn failure_signature_ignores_numbers_and_paths() {
8997        assert_eq!(
8998            failure_signature("exited with Some(2) and no usable output"),
8999            failure_signature("exited with Some(137) and no usable output")
9000        );
9001        assert_eq!(
9002            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9003            failure_signature("cannot open /var/x.txt: denied")
9004        );
9005        assert_ne!(failure_signature("boom"), failure_signature("bang"));
9006    }
9007
9008    #[test]
9009    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9010        let other = FailClass::Other("x".into());
9011        assert!(should_hand_over(None, &FailClass::Quota));
9012        assert!(should_hand_over(Some(&other), &FailClass::Quota));
9013        assert!(should_hand_over(
9014            Some(&FailClass::Timeout),
9015            &FailClass::Timeout
9016        ));
9017        assert!(should_hand_over(None, &other));
9018        assert!(!should_hand_over(Some(&other), &other));
9019        assert!(should_hand_over(
9020            Some(&other),
9021            &FailClass::Other("y".into())
9022        ));
9023        // A quota or timeout in between ends the run of identical failures.
9024        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9025        assert!(should_hand_over(Some(&FailClass::Quota), &other));
9026    }
9027
9028    #[test]
9029    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9030        let a = SeatState::new("judge-1", "alpha", 7);
9031        let b = handover_seat("judge-1", "beta", 7);
9032        assert_ne!(a.claude_session, b.claude_session);
9033        assert_eq!(b.turns, 0);
9034    }
9035
9036    #[test]
9037    fn a_timeout_is_classified_apart_from_other_failures() {
9038        assert_eq!(
9039            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9040            Some(FailClass::Timeout)
9041        );
9042        assert!(matches!(
9043            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9044            Some(FailClass::Other(_))
9045        ));
9046    }
9047
9048    #[test]
9049    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9050        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9051        let tried = BTreeSet::from(["beta".to_owned()]);
9052        // beta sits at index 1; the next candidate is gamma, never alpha —
9053        // which is very likely a different candidate slot's own agent.
9054        let next = next_untried_in_roster(&roster, 1, &tried);
9055        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9056    }
9057
9058    #[test]
9059    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9060        let roster = vec![spec("alpha"), spec("beta")];
9061        let tried = BTreeSet::from(["beta".to_owned()]);
9062        // beta is the roster's last entry: nothing follows it, and alpha —
9063        // earlier in the roster, almost certainly a different candidate
9064        // slot's own agent — must not be reached by wrapping back to it.
9065        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9066    }
9067
9068    #[test]
9069    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9070        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9071        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9072        // beta (index 1) and gamma (index 2, the only entry after it) have
9073        // both been tried; alpha (index 0) never has, but it comes before
9074        // beta's own position, so there is nothing further for this seat.
9075        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9076    }
9077
9078    #[test]
9079    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9080        let roster = vec![spec("a"), spec("a"), spec("b")];
9081        let tried = BTreeSet::from(["a".to_owned()]);
9082        let next = next_untried_in_roster(&roster, 0, &tried);
9083        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9084    }
9085
9086    #[test]
9087    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9088        let roster = vec![spec("a"), spec("b")];
9089        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9090        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9091    }
9092
9093    #[test]
9094    fn remove_if_empty_only_ever_takes_a_bare_directory() {
9095        let dir = tempfile::tempdir().unwrap();
9096        let bay = dir.path().join("ffff");
9097
9098        // Not there yet: nothing to do, nothing to panic on.
9099        remove_if_empty(&bay);
9100        assert!(!bay.exists());
9101
9102        // Something still inside - the winner's worktree, or a stray file -
9103        // keeps the directory standing.
9104        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9105        remove_if_empty(&bay);
9106        assert!(bay.exists(), "non-empty directory must survive");
9107
9108        // Once the last entry is gone, so is the directory itself.
9109        std::fs::remove_dir(bay.join("cand-A")).unwrap();
9110        remove_if_empty(&bay);
9111        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9112    }
9113
9114    // `round_is_clean` is the exact decision this task fixed: a round with a
9115    // seat that never answered must not read the same as a round every seat
9116    // actually reviewed. These are deterministic and process-free by design —
9117    // the equivalent end-to-end check (a real reviewer timing out under a
9118    // live graph run) is a genuine race against wall-clock contention, and a
9119    // spawn slow enough to blow even a generous budget under a loaded test
9120    // run must not turn this specific regression check flaky.
9121
9122    #[test]
9123    fn a_full_panel_that_found_nothing_is_clean() {
9124        assert!(round_is_clean(
9125            0,
9126            true,
9127            2,
9128            2,
9129            0,
9130            IncompleteReviewPolicy::Block
9131        ));
9132    }
9133
9134    #[test]
9135    fn a_missing_seat_is_never_clean_under_the_default_policy() {
9136        assert!(!round_is_clean(
9137            0,
9138            true,
9139            1,
9140            2,
9141            0,
9142            IncompleteReviewPolicy::Block
9143        ));
9144    }
9145
9146    #[test]
9147    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9148        assert!(!round_is_clean(
9149            1,
9150            true,
9151            1,
9152            2,
9153            0,
9154            IncompleteReviewPolicy::Warn
9155        ));
9156    }
9157
9158    #[test]
9159    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9160        assert!(round_is_clean(
9161            0,
9162            true,
9163            1,
9164            2,
9165            0,
9166            IncompleteReviewPolicy::Warn
9167        ));
9168    }
9169
9170    #[test]
9171    fn a_full_panel_with_an_open_finding_is_not_clean() {
9172        assert!(!round_is_clean(
9173            1,
9174            true,
9175            2,
9176            2,
9177            0,
9178            IncompleteReviewPolicy::Block
9179        ));
9180    }
9181
9182    #[test]
9183    fn a_full_panel_with_a_red_e2e_is_not_clean() {
9184        assert!(!round_is_clean(
9185            0,
9186            false,
9187            2,
9188            2,
9189            0,
9190            IncompleteReviewPolicy::Block
9191        ));
9192    }
9193
9194    // The stall this task closes: under the default `block` policy, a seat
9195    // missing only because it was rate limited must not force a wait for a
9196    // session limit that will not lift by the next round. `round_is_clean`
9197    // is where that quorum carve-out lives; the review loop around it never
9198    // changes what a reviewer's vote or a finding's severity means.
9199
9200    #[test]
9201    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9202        // 1 of 2 answered, and the one missing was quota'd — the exact
9203        // "review-2 rate limited (quota)" shape from the field report.
9204        assert!(round_is_clean(
9205            0,
9206            true,
9207            1,
9208            2,
9209            1,
9210            IncompleteReviewPolicy::Block
9211        ));
9212    }
9213
9214    #[test]
9215    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9216        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
9217        // not a quota loss (`quota_missing` stays 0) — worth another try.
9218        assert!(!round_is_clean(
9219            0,
9220            true,
9221            1,
9222            2,
9223            0,
9224            IncompleteReviewPolicy::Block
9225        ));
9226    }
9227
9228    #[test]
9229    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9230        assert!(!round_is_clean(
9231            1,
9232            true,
9233            1,
9234            2,
9235            1,
9236            IncompleteReviewPolicy::Block
9237        ));
9238        assert!(!round_is_clean(
9239            0,
9240            false,
9241            1,
9242            2,
9243            1,
9244            IncompleteReviewPolicy::Block
9245        ));
9246    }
9247
9248    #[test]
9249    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9250        // Every seat quota'd, nobody answered: there is no panel to decide
9251        // on, so this must fall through to the existing block-and-retry
9252        // fallback rather than call an unreviewed patch clean.
9253        assert!(!round_is_clean(
9254            0,
9255            true,
9256            0,
9257            2,
9258            2,
9259            IncompleteReviewPolicy::Block
9260        ));
9261    }
9262
9263    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9264        CommandOutcome {
9265            command: "test".to_owned(),
9266            code,
9267            output_tail: String::new(),
9268            duration_ms: 0,
9269            resource_blocked,
9270        }
9271    }
9272
9273    #[test]
9274    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9275        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9276        assert!(
9277            !verify_inconclusive(&[outcome(Some(1), false)]),
9278            "an ordinary failure is still evidence about the patch"
9279        );
9280        assert!(verify_inconclusive(&[outcome(None, true)]));
9281        assert!(
9282            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9283            "one inconclusive outcome taints the whole batch"
9284        );
9285        assert!(!verify_inconclusive(&[]));
9286    }
9287
9288    #[tokio::test]
9289    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9290        // Alive for the first two checks, then dead - confirms the loop
9291        // actually re-polls rather than deciding once and sleeping out the
9292        // ceiling regardless.
9293        let calls = std::sync::atomic::AtomicUsize::new(0);
9294        let started = Instant::now();
9295        wait_for_pids_with(
9296            &[123],
9297            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9298            Duration::from_millis(5),
9299            Duration::from_secs(5),
9300        )
9301        .await;
9302        assert!(
9303            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9304            "must keep checking rather than deciding on the first answer"
9305        );
9306        assert!(
9307            started.elapsed() < Duration::from_secs(1),
9308            "must return the moment it is confirmed dead, not wait out the ceiling"
9309        );
9310    }
9311
9312    #[tokio::test]
9313    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9314        let started = Instant::now();
9315        wait_for_pids_with(
9316            &[123],
9317            |_| true, // never reports dead
9318            Duration::from_millis(5),
9319            Duration::from_millis(30),
9320        )
9321        .await;
9322        let elapsed = started.elapsed();
9323        assert!(
9324            elapsed >= Duration::from_millis(30),
9325            "must not give up before its own ceiling: {elapsed:?}"
9326        );
9327        assert!(
9328            elapsed < Duration::from_secs(1),
9329            "must not wait past its own ceiling either: {elapsed:?}"
9330        );
9331    }
9332
9333    #[tokio::test]
9334    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9335        let started = Instant::now();
9336        wait_for_pids_with(
9337            &[],
9338            |_| true,
9339            Duration::from_secs(5),
9340            Duration::from_secs(5),
9341        )
9342        .await;
9343        assert!(
9344            started.elapsed() < Duration::from_millis(200),
9345            "an empty pid list has nothing to confirm"
9346        );
9347    }
9348
9349    // `review_conclusion` is the exact decision the review hand-off task
9350    // fixed: a round budget spent (or a tree that stopped moving) must not
9351    // collapse into `Blocked` regardless of what verification actually
9352    // said. Deterministic and process-free for the same reason the
9353    // `round_is_clean` family above is.
9354    fn review_round(
9355        clean: bool,
9356        blocking: usize,
9357        answered: usize,
9358        expected: usize,
9359        progressed: bool,
9360        e2e_ok: bool,
9361    ) -> ReviewRound {
9362        ReviewRound {
9363            round: 1,
9364            head: "h".to_owned(),
9365            verified_head: None,
9366            verified_at: None,
9367            reviews: Vec::new(),
9368            e2e: vec![CommandOutcome {
9369                command: "test".to_owned(),
9370                code: Some(if e2e_ok { 0 } else { 1 }),
9371                output_tail: String::new(),
9372                duration_ms: 0,
9373                resource_blocked: false,
9374            }],
9375            verify_retried: false,
9376            e2e_deferred: false,
9377            e2e_defer_reason: None,
9378            fix: None,
9379            blocking,
9380            answered,
9381            expected,
9382            clean,
9383            progressed,
9384            vote_split: false,
9385            reconsideration: Vec::new(),
9386            verdict: None,
9387        }
9388    }
9389
9390    #[test]
9391    fn review_conclusion_is_none_when_nothing_has_run() {
9392        assert_eq!(review_conclusion(&[], 3), None);
9393    }
9394
9395    #[test]
9396    fn review_conclusion_is_none_while_rounds_remain() {
9397        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9398        assert_eq!(review_conclusion(&rounds, 3), None);
9399    }
9400
9401    #[test]
9402    fn review_conclusion_is_gating_once_a_round_is_clean() {
9403        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9404        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9405    }
9406
9407    #[test]
9408    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9409        let rounds = vec![
9410            review_round(false, 1, 2, 2, true, true),
9411            review_round(false, 1, 2, 2, true, true),
9412        ];
9413        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9414    }
9415
9416    #[test]
9417    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9418        let rounds = vec![
9419            review_round(false, 1, 2, 2, true, true),
9420            review_round(false, 1, 2, 2, true, false),
9421        ];
9422        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9423    }
9424
9425    #[test]
9426    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9427        // Magi never got a command to run against this round's own head — a
9428        // resource-blocked attempt, not a red one — so this must never
9429        // settle on `Blocked` the way a genuine e2e failure would. `None`
9430        // here is what tells `Runner::review_loop` to retry the check
9431        // itself rather than trust this cheap recomputation with a verdict
9432        // it cannot actually produce.
9433        let mut blocked = review_round(false, 1, 2, 2, true, false);
9434        blocked.e2e[0].resource_blocked = true;
9435        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9436        assert_eq!(review_conclusion(&rounds, 2), None);
9437    }
9438
9439    #[test]
9440    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9441        // Missing input, not a verified tree — never a hand-off candidate.
9442        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9443        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9444    }
9445
9446    #[test]
9447    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9448        let rounds = vec![
9449            review_round(false, 1, 2, 2, false, true),
9450            review_round(false, 1, 2, 2, false, true),
9451        ];
9452        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9453    }
9454
9455    fn secs(n: u64) -> Duration {
9456        Duration::from_secs(n)
9457    }
9458
9459    /// A throwaway repo with one commit on `main`, for tests that need `merge`
9460    /// to make real (and, if it runs at all, real*ly fail*) git calls.
9461    fn init_repo(dir: &Path) {
9462        let run = |args: &[&str]| {
9463            let out = std::process::Command::new("git")
9464                .args(args)
9465                .current_dir(dir)
9466                .quiet()
9467                .output()
9468                .expect("spawn git");
9469            assert!(
9470                out.status.success(),
9471                "git {args:?} failed: {}",
9472                String::from_utf8_lossy(&out.stderr)
9473            );
9474        };
9475        run(&["init", "-b", "main"]);
9476        run(&["config", "user.name", "magi test"]);
9477        run(&["config", "user.email", "magi@example.com"]);
9478        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9479        run(&["add", "-A"]);
9480        run(&["commit", "-m", "init"]);
9481    }
9482
9483    // `settle_questions` is what closes the ghost the phone showed: a run's
9484    // seat asked something, the run then ended, and nothing was left to
9485    // abandon the question it left `open`. `HOME` is a process-wide
9486    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
9487    // first time it runs in the binary — every test below still reaches the
9488    // same directory whichever call won, and each gets its own run id from
9489    // `RunState::new`, so they never collide there.
9490    fn ask_test_home() {
9491        crate::run::pin_test_home();
9492    }
9493
9494    /// A minimal, git-free `Runner` at a given status — `settle_questions`
9495    /// reads nothing else off it.
9496    fn runner_at(status: RunStatus) -> Runner {
9497        let mut state = RunState::new(
9498            PathBuf::from("/nonexistent/repo"),
9499            "main".to_owned(),
9500            "deadbeef".to_owned(),
9501            "task".to_owned(),
9502            Config::default(),
9503        );
9504        state.status = status;
9505        Runner {
9506            state,
9507            roles: ResolvedRoles {
9508                implementers: Vec::new(),
9509                judges: Vec::new(),
9510                reviewers: Vec::new(),
9511                fixer: None,
9512                conductor: conductor(),
9513                implementer_roster: Vec::new(),
9514                judge_roster: Vec::new(),
9515                reviewer_roster: Vec::new(),
9516            },
9517            sem: Arc::new(Semaphore::new(1)),
9518            pause: Pause::new(),
9519            interrupt: Pause::new(),
9520        }
9521    }
9522
9523    /// `park_here` folding in the reason `Pause::park_because` recorded -
9524    /// this is what lets an operator reading a run's events tell an
9525    /// interrupt-driven park from an ordinary shutdown park.
9526    #[test]
9527    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9528        crate::run::pin_test_home();
9529        let mut runner = runner_at(RunStatus::Implementing);
9530        let interrupt = Pause::new();
9531        runner.watch_interrupt(interrupt.clone());
9532
9533        interrupt.park_because("task a1b2 asked to run first");
9534
9535        assert!(runner.park_here().expect("park_here"));
9536        assert!(runner.state.parked);
9537        let last = runner.state.events.last().expect("a park event");
9538        assert_eq!(last.node, "park");
9539        assert!(
9540            last.message.contains("task a1b2 asked to run first"),
9541            "expected the interrupt reason in {:?}",
9542            last.message
9543        );
9544    }
9545
9546    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
9547    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
9548    /// cleared) must not make a *different* run - one only watching its own,
9549    /// unshared interrupt `Pause` - see itself as parked. If a future change
9550    /// ever collapsed these back into one handle, the interrupt scheduler
9551    /// would park every run for the rest of the daemon's life, not just the
9552    /// one it meant to interrupt.
9553    #[test]
9554    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9555        crate::run::pin_test_home();
9556        let mut runner = runner_at(RunStatus::Implementing);
9557        let shutdown = Pause::new();
9558        runner.on_pause(shutdown.clone());
9559        let interrupt = Pause::new();
9560        runner.watch_interrupt(interrupt.clone());
9561
9562        // Nobody has asked for anything yet.
9563        assert!(!runner.park_here().expect("park_here"));
9564        assert!(!runner.state.parked);
9565
9566        // Only the interrupt handle fires; the shutdown handle stays clear.
9567        interrupt.park_because("test");
9568        assert!(!shutdown.parked());
9569        assert!(runner.park_here().expect("park_here"));
9570    }
9571
9572    /// The property every prior attempt at this feature failed to pin down:
9573    /// asking a run to park while one of its nodes has a real, in-flight
9574    /// async operation running (an agent call, in production) must not cut
9575    /// that operation short. `park_here` is only ever consulted *between*
9576    /// `execute`'s node calls - see its own doc - so nothing inside a node
9577    /// can observe a park request until the node itself returns. This proves
9578    /// that structurally, with real `tokio` concurrency and a channel
9579    /// handshake (never a sleep, which would only prove "usually", not
9580    /// "cannot"): the "node" below reports that it has genuinely started,
9581    /// and only then is the park requested; the node still has to be told to
9582    /// finish before `park_here` is ever called, exactly mirroring every
9583    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
9584    /// in `execute`.
9585    #[tokio::test]
9586    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9587        crate::run::pin_test_home();
9588        let mut runner = runner_at(RunStatus::Implementing);
9589        let interrupt = Pause::new();
9590        runner.watch_interrupt(interrupt.clone());
9591
9592        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9593        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9594
9595        // Stands in for one node's in-flight agent call: it proves it has
9596        // genuinely started, then blocks - exactly as a spawned CLI process
9597        // does - until told to finish.
9598        let node = async move {
9599            started_tx.send(()).expect("send started");
9600            finish_rx.await.expect("recv finish");
9601            "node finished"
9602        };
9603
9604        let interrupter = async move {
9605            started_rx.await.expect("recv started");
9606            // The call is now genuinely in flight. Ask it to park.
9607            interrupt.park_because("higher-priority task waiting");
9608            // Nothing the node does can observe this yet - there is no
9609            // check inside it, by construction - so let the executor run
9610            // anything pending and then let the node finish on its own.
9611            tokio::task::yield_now().await;
9612            finish_tx.send(()).expect("send finish");
9613        };
9614
9615        let (node_result, ()) = tokio::join!(node, interrupter);
9616        assert_eq!(
9617            node_result, "node finished",
9618            "the in-flight call ran to completion"
9619        );
9620
9621        // Only now, at the boundary the real `execute` would check right
9622        // after this node, does the park take effect.
9623        assert!(runner.park_here().expect("park_here"));
9624        assert!(runner.state.parked);
9625    }
9626
9627    /// A run parked mid-competition carries every field it had accumulated
9628    /// through the exact same disk round-trip an ordinary resume uses -
9629    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
9630    /// Nothing about parking for an interrupt is a special case of that path;
9631    /// this is what proves it rather than assuming it.
9632    #[test]
9633    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9634        crate::run::pin_test_home();
9635        let mut runner = runner_at(RunStatus::Judging);
9636        // `Runner::resume` re-resolves roles from the saved config, which
9637        // refuses an empty roster - give it the same minimal one `conductor`
9638        // itself uses.
9639        runner.state.config.agents = vec![conductor()];
9640        runner.state.candidates = vec![Candidate {
9641            index: 0,
9642            label: 'A',
9643            agent: "alpha".to_owned(),
9644            branch: "magi/x/A".to_owned(),
9645            worktree: PathBuf::from("/nonexistent/worktree"),
9646            summary: "did the thing".to_owned(),
9647            stat: "1 file changed".to_owned(),
9648            files: 1,
9649            commits: 1,
9650            empty: false,
9651            failed: None,
9652            verified_noop: None,
9653            duration_ms: 1234,
9654            folded: false,
9655        }];
9656        let run_id = runner.state.id.clone();
9657
9658        let interrupt = Pause::new();
9659        runner.watch_interrupt(interrupt.clone());
9660        interrupt.park_because("task c3d4 asked to run first");
9661        assert!(runner.park_here().expect("park_here"));
9662
9663        let resumed = Runner::resume(&run_id).expect("resume");
9664        assert_eq!(resumed.state.candidates.len(), 1);
9665        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9666        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9667        assert_eq!(resumed.state.status, runner.state.status);
9668        assert!(
9669            resumed.state.parked,
9670            "still parked until `execute` actually walks the graph again"
9671        );
9672        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9673    }
9674
9675    /// A fresh open question on `run`, stored and handed back for assertions.
9676    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9677        let mut q = ask::Question::new(
9678            run.to_owned(),
9679            "implement".to_owned(),
9680            "impl-A".to_owned(),
9681            "Which storage backend should the cache use?".to_owned(),
9682            String::new(),
9683            vec!["SQLite".to_owned(), "Redis".to_owned()],
9684        );
9685        store.put(&mut q).unwrap();
9686        q
9687    }
9688
9689    #[test]
9690    fn a_failed_runs_open_question_is_abandoned() {
9691        ask_test_home();
9692        let store = ask::Questions::open();
9693        let mut runner = runner_at(RunStatus::Failed);
9694        let run = runner.state.id.clone();
9695        let q = ask_open_question(&store, &run);
9696
9697        runner.settle_questions();
9698
9699        let back = store.get(&q.id).unwrap();
9700        assert!(
9701            !back.status.open(),
9702            "the seat that asked died with the run; nobody is left to read an answer"
9703        );
9704        assert!(
9705            back.detail.contains(&run) && back.detail.contains("failed"),
9706            "the reason names what the run became, not just that it is gone: {}",
9707            back.detail
9708        );
9709    }
9710
9711    #[test]
9712    fn a_merged_runs_open_question_is_abandoned_too() {
9713        ask_test_home();
9714        let store = ask::Questions::open();
9715        // A run that finishes cleanly still leaves nobody to read an answer -
9716        // this is not only a failure-path cleanup.
9717        for status in [RunStatus::Merged, RunStatus::Ready] {
9718            let mut runner = runner_at(status);
9719            let run = runner.state.id.clone();
9720            let q = ask_open_question(&store, &run);
9721
9722            runner.settle_questions();
9723
9724            let back = store.get(&q.id).unwrap();
9725            assert!(
9726                !back.status.open(),
9727                "{status:?} run's question must not outlive the run"
9728            );
9729        }
9730    }
9731
9732    #[test]
9733    fn a_still_resumable_runs_open_question_is_left_alone() {
9734        ask_test_home();
9735        let store = ask::Questions::open();
9736        // `Blocked` and `Stalled` can still be resumed — the candidates, the
9737        // review round and the seat sessions are all still on disk — so a
9738        // question asked mid-round may yet get a real answer from a real
9739        // resume. Sweeping it here would be exactly the failure mode this
9740        // whole feature exists to avoid on the other side.
9741        for status in [RunStatus::Blocked, RunStatus::Stalled] {
9742            let mut runner = runner_at(status);
9743            let run = runner.state.id.clone();
9744            let q = ask_open_question(&store, &run);
9745
9746            runner.settle_questions();
9747
9748            let back = store.get(&q.id).unwrap();
9749            assert!(
9750                back.status.open(),
9751                "{status:?} is still alive; the question must still be waiting"
9752            );
9753        }
9754    }
9755
9756    #[test]
9757    fn settle_questions_never_touches_an_already_answered_question() {
9758        ask_test_home();
9759        let store = ask::Questions::open();
9760        let mut runner = runner_at(RunStatus::Failed);
9761        let run = runner.state.id.clone();
9762        let mut q = ask_open_question(&store, &run);
9763        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9764            .unwrap();
9765        store.put(&mut q).unwrap();
9766
9767        // Called twice, the way a crash-recovered daemon reclaim and the
9768        // graph's own cleanup both can for the same run — `abandon_for_run`
9769        // only ever touches what is still open, so this must be inert both
9770        // times, not merely the second.
9771        runner.settle_questions();
9772        runner.settle_questions();
9773
9774        let back = store.get(&q.id).unwrap();
9775        assert_eq!(
9776            back.status,
9777            ask::QuestionStatus::Answered,
9778            "a real answer is a decision on record, never overwritten by a sweep"
9779        );
9780    }
9781
9782    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
9783    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
9784    /// without merging, whose winner is still the operator's answer to read.
9785    /// Nothing previously called `fold_run` itself with a real `tally`, so
9786    /// this is the first test to pin down the one distinction the whole
9787    /// automatic-fold feature depends on: the winner's worktree and branch
9788    /// must survive, everything else sharing the run's worktree bay - a
9789    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
9790    /// second sweep treats every non-winner directory under the bay alike -
9791    /// must not.
9792    #[tokio::test]
9793    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9794        crate::run::pin_test_home();
9795        let tmp = tempfile::tempdir().expect("tempdir");
9796        let repo = tmp.path().join("repo");
9797        std::fs::create_dir_all(&repo).unwrap();
9798        init_repo(&repo);
9799
9800        let mut config = Config::default();
9801        config.graph.worktree_root = Some(tmp.path().join("wt"));
9802
9803        let mut state = RunState::new(
9804            repo.clone(),
9805            "main".to_owned(),
9806            "deadbeef".to_owned(),
9807            "task".to_owned(),
9808            config,
9809        );
9810        let root = state.worktree_root();
9811        let wt_a = root.join("cand-A");
9812        let wt_b = root.join("cand-B");
9813        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9814            .await
9815            .expect("worktree A");
9816        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9817            .await
9818            .expect("worktree B");
9819
9820        state.candidates = vec![
9821            Candidate {
9822                index: 0,
9823                label: 'A',
9824                agent: "alpha".to_owned(),
9825                branch: "magi/x/A".to_owned(),
9826                worktree: wt_a.clone(),
9827                summary: String::new(),
9828                stat: String::new(),
9829                files: 0,
9830                commits: 0,
9831                empty: false,
9832                failed: None,
9833                verified_noop: None,
9834                duration_ms: 0,
9835                folded: false,
9836            },
9837            Candidate {
9838                index: 1,
9839                label: 'B',
9840                agent: "beta".to_owned(),
9841                branch: "magi/x/B".to_owned(),
9842                worktree: wt_b.clone(),
9843                summary: String::new(),
9844                stat: String::new(),
9845                files: 0,
9846                commits: 0,
9847                empty: false,
9848                failed: None,
9849                verified_noop: None,
9850                duration_ms: 0,
9851                folded: false,
9852            },
9853        ];
9854        state.tally = Some(Tally {
9855            first_choice: BTreeMap::from([('A', 1)]),
9856            borda: BTreeMap::new(),
9857            winner: 'A',
9858            rankings: 1,
9859            unanimous_initial: true,
9860            deliberated: false,
9861            changed_votes: 0,
9862            unanimous_final: true,
9863            tie_break: None,
9864            judges: 1,
9865            present: 1,
9866            quorum: 1,
9867            met_quorum: true,
9868            uncontested: None,
9869        });
9870        state.status = RunStatus::Ready;
9871
9872        fold_run(&mut state, false, &crate::run::home())
9873            .await
9874            .expect("fold_run");
9875
9876        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9877        assert!(
9878            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9879            "the unmerged winner's branch survives"
9880        );
9881        assert!(
9882            !state.candidates[0].folded,
9883            "the winner is not marked folded"
9884        );
9885
9886        assert!(!wt_b.exists(), "the loser's worktree is removed");
9887        assert!(
9888            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9889            "the loser's branch is removed"
9890        );
9891        assert!(state.candidates[1].folded, "the loser is marked folded");
9892    }
9893
9894    /// A branch handed to a later run is that run's (and its pull request's):
9895    /// folding the run that released it must not delete it.
9896    #[tokio::test]
9897    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9898        let tmp = tempfile::tempdir().expect("tempdir");
9899        let repo = tmp.path().join("repo");
9900        std::fs::create_dir_all(&repo).unwrap();
9901        init_repo(&repo);
9902        let home = tmp.path().join("home");
9903
9904        let mut config = Config::default();
9905        config.graph.worktree_root = Some(tmp.path().join("wt"));
9906        let mut state = RunState::new(
9907            repo.clone(),
9908            "main".to_owned(),
9909            "deadbeef".to_owned(),
9910            "task".to_owned(),
9911            config,
9912        );
9913        // The worktree is already gone (released); the branch survives.
9914        git::git(&repo, &["branch", "magi/x/A", "main"])
9915            .await
9916            .expect("branch");
9917        state.candidates = vec![Candidate {
9918            index: 0,
9919            label: 'A',
9920            agent: "alpha".to_owned(),
9921            branch: "magi/x/A".to_owned(),
9922            worktree: state.worktree_root().join("cand-A"),
9923            summary: String::new(),
9924            stat: String::new(),
9925            files: 0,
9926            commits: 0,
9927            empty: false,
9928            failed: None,
9929            verified_noop: None,
9930            duration_ms: 0,
9931            folded: true,
9932        }];
9933        state.released_to = Some("20260901-000000-new1".to_owned());
9934        state.released_branches = vec!["magi/x/A".to_owned()];
9935
9936        fold_run(&mut state, true, &home).await.expect("fold_run");
9937
9938        assert!(
9939            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9940            "the handed-over branch survives a fold"
9941        );
9942    }
9943
9944    /// A winner with nothing ahead of the base is caught before `gh` is ever
9945    /// asked for a pull request, and the message carries what the task's
9946    /// references resolved to.
9947    #[tokio::test]
9948    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9949        let tmp = tempfile::tempdir().expect("tempdir");
9950        let repo = tmp.path().join("repo");
9951        std::fs::create_dir_all(&repo).unwrap();
9952        init_repo(&repo);
9953        let run = |args: &[&str]| {
9954            let out = std::process::Command::new("git")
9955                .quiet()
9956                .args(args)
9957                .current_dir(&repo)
9958                .output()
9959                .expect("spawn git");
9960            assert!(out.status.success(), "git {args:?}");
9961        };
9962        run(&["branch", "magi/x/A"]);
9963        run(&["checkout", "-q", "-b", "magi/x/B"]);
9964        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9965        run(&["add", "-A"]);
9966        run(&["commit", "-q", "-m", "work"]);
9967        run(&["checkout", "-q", "main"]);
9968
9969        // A pull request is compared against the remote's base, so the
9970        // fixture needs one. Before it exists nothing can be read, and the
9971        // answer must be "not empty".
9972        let probe = RunState::new(
9973            repo.clone(),
9974            "main".to_owned(),
9975            "deadbeef".to_owned(),
9976            "task".to_owned(),
9977            Config::default(),
9978        );
9979        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
9980        let bare = tmp.path().join("origin.git");
9981        let out = std::process::Command::new("git")
9982            .quiet()
9983            .args(["init", "-q", "--bare"])
9984            .arg(&bare)
9985            .output()
9986            .expect("spawn git");
9987        assert!(out.status.success(), "git init --bare");
9988        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
9989        run(&["push", "-q", "origin", "main"]);
9990
9991        let mut state = RunState::new(
9992            repo.clone(),
9993            "main".to_owned(),
9994            "deadbeef".to_owned(),
9995            "task".to_owned(),
9996            Config::default(),
9997        );
9998        state.seeds = vec![refs::Seed {
9999            token: "magi/27b2/A".to_owned(),
10000            kind: refs::SeedKind::Unresolved,
10001            sha: String::new(),
10002            branch: true,
10003            detail: "no branch or commit named magi/27b2/A".to_owned(),
10004        }];
10005
10006        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10007        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10008        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10009        let detail = empty_candidate_detail(&state, "main");
10010        assert!(detail.starts_with("empty candidate"), "{detail}");
10011        assert!(detail.contains("magi/27b2/A"), "{detail}");
10012    }
10013
10014    /// `status == Ready` used to be read as "this is the harmless
10015    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
10016    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
10017    /// run's PR was closed without merging — and reentering `merge` with
10018    /// `mode` still `Pr` does not know the difference, so it pushed and
10019    /// opened a second pull request. `mode == Local` reproduces the same
10020    /// blind spot without a network call: reentry must not attempt another
10021    /// git merge once this node has already recorded an outcome.
10022    #[tokio::test]
10023    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10024        let tmp = tempfile::tempdir().expect("tempdir");
10025        let repo = tmp.path().join("repo");
10026        std::fs::create_dir_all(&repo).unwrap();
10027        init_repo(&repo);
10028
10029        let mut config = Config::default();
10030        config.merge.mode = MergeMode::Local;
10031
10032        let mut state = RunState::new(
10033            repo.clone(),
10034            "main".to_owned(),
10035            "deadbeef".to_owned(),
10036            "task".to_owned(),
10037            config,
10038        );
10039        state.candidates = vec![Candidate {
10040            index: 0,
10041            label: 'A',
10042            agent: "alpha".to_owned(),
10043            branch: "does-not-exist".to_owned(),
10044            worktree: repo.clone(),
10045            summary: String::new(),
10046            stat: String::new(),
10047            files: 0,
10048            commits: 0,
10049            empty: false,
10050            failed: None,
10051            verified_noop: None,
10052            duration_ms: 0,
10053            folded: false,
10054        }];
10055        state.tally = Some(Tally {
10056            first_choice: BTreeMap::from([('A', 1)]),
10057            borda: BTreeMap::new(),
10058            winner: 'A',
10059            rankings: 1,
10060            unanimous_initial: true,
10061            deliberated: false,
10062            changed_votes: 0,
10063            unanimous_final: true,
10064            tie_break: None,
10065            judges: 0,
10066            present: 0,
10067            quorum: 0,
10068            met_quorum: true,
10069            uncontested: Some("only candidate A produced a change".to_owned()),
10070        });
10071        state.reviews = vec![ReviewRound {
10072            round: 1,
10073            head: "deadbeef".to_owned(),
10074            verified_head: None,
10075            verified_at: None,
10076            reviews: Vec::new(),
10077            e2e: Vec::new(),
10078            fix: None,
10079            blocking: 0,
10080            answered: 0,
10081            expected: 0,
10082            clean: true,
10083            verify_retried: false,
10084            e2e_deferred: false,
10085            e2e_defer_reason: None,
10086            progressed: false,
10087            vote_split: false,
10088            reconsideration: Vec::new(),
10089            verdict: None,
10090        }];
10091        state.gate = vec![CommandOutcome {
10092            command: "test".to_owned(),
10093            code: Some(0),
10094            output_tail: String::new(),
10095            duration_ms: 0,
10096            resource_blocked: false,
10097        }];
10098        state.gate_ran = true;
10099        // Reached its conclusion already — e.g. `land` closing the PR without
10100        // merging it, which (like the honest `MergeMode::None` path) leaves
10101        // `status` at `Ready`. The recorded outcome is what actually marks
10102        // this node done.
10103        state.status = RunStatus::Ready;
10104        state.merge = Some(MergeOutcome {
10105            mode: MergeMode::Local,
10106            ok: false,
10107            detail: "already concluded".to_owned(),
10108            empty: false,
10109        });
10110
10111        let mut runner = Runner {
10112            state,
10113            roles: ResolvedRoles {
10114                implementers: Vec::new(),
10115                judges: Vec::new(),
10116                reviewers: Vec::new(),
10117                fixer: None,
10118                conductor: conductor(),
10119                implementer_roster: Vec::new(),
10120                judge_roster: Vec::new(),
10121                reviewer_roster: Vec::new(),
10122            },
10123            sem: Arc::new(Semaphore::new(1)),
10124            pause: Pause::new(),
10125            interrupt: Pause::new(),
10126        };
10127
10128        runner.merge().await.expect("merge");
10129
10130        assert_eq!(
10131            runner.state.status,
10132            RunStatus::Ready,
10133            "a concluded run's status must not change on reentry"
10134        );
10135        assert_eq!(
10136            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10137            Some("already concluded"),
10138            "merge must not run again once the node already recorded an outcome"
10139        );
10140    }
10141
10142    /// `gate` leaves `state.gate_ran` false both before it has ever run and
10143    /// when its last attempt was resource-blocked (the shared build cache
10144    /// could not be acquired or confirmed fresh in time - see
10145    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
10146    /// `Vec` this also leaves behind used to read as "nothing failed" and let
10147    /// a run merge a tree the gate never actually checked - exactly the case
10148    /// a contended cache produces on every retry until it clears. `merge`
10149    /// must refuse until `gate` has actually recorded an attempt.
10150    #[tokio::test]
10151    async fn merge_refuses_a_gate_that_has_not_actually_run() {
10152        let tmp = tempfile::tempdir().expect("tempdir");
10153        let repo = tmp.path().join("repo");
10154        std::fs::create_dir_all(&repo).unwrap();
10155        init_repo(&repo);
10156
10157        let mut config = Config::default();
10158        config.merge.mode = MergeMode::Local;
10159
10160        let mut state = RunState::new(
10161            repo.clone(),
10162            "main".to_owned(),
10163            "deadbeef".to_owned(),
10164            "task".to_owned(),
10165            config,
10166        );
10167        state.candidates = vec![Candidate {
10168            index: 0,
10169            label: 'A',
10170            agent: "alpha".to_owned(),
10171            branch: "does-not-exist".to_owned(),
10172            worktree: repo.clone(),
10173            summary: String::new(),
10174            stat: String::new(),
10175            files: 0,
10176            commits: 0,
10177            empty: false,
10178            failed: None,
10179            verified_noop: None,
10180            duration_ms: 0,
10181            folded: false,
10182        }];
10183        state.tally = Some(Tally {
10184            first_choice: BTreeMap::from([('A', 1)]),
10185            borda: BTreeMap::new(),
10186            winner: 'A',
10187            rankings: 1,
10188            unanimous_initial: true,
10189            deliberated: false,
10190            changed_votes: 0,
10191            unanimous_final: true,
10192            tie_break: None,
10193            judges: 0,
10194            present: 0,
10195            quorum: 0,
10196            met_quorum: true,
10197            uncontested: Some("only candidate A produced a change".to_owned()),
10198        });
10199        state.reviews = vec![ReviewRound {
10200            round: 1,
10201            head: "deadbeef".to_owned(),
10202            verified_head: None,
10203            verified_at: None,
10204            reviews: Vec::new(),
10205            e2e: Vec::new(),
10206            fix: None,
10207            blocking: 0,
10208            answered: 0,
10209            expected: 0,
10210            clean: true,
10211            verify_retried: false,
10212            e2e_deferred: false,
10213            e2e_defer_reason: None,
10214            progressed: false,
10215            vote_split: false,
10216            reconsideration: Vec::new(),
10217            verdict: None,
10218        }];
10219        // The point: `gate` has not recorded anything yet.
10220        state.gate = Vec::new();
10221        state.gate_ran = false;
10222        state.status = RunStatus::Gating;
10223
10224        let mut runner = Runner {
10225            state,
10226            roles: ResolvedRoles {
10227                implementers: Vec::new(),
10228                judges: Vec::new(),
10229                reviewers: Vec::new(),
10230                fixer: None,
10231                conductor: conductor(),
10232                implementer_roster: Vec::new(),
10233                judge_roster: Vec::new(),
10234                reviewer_roster: Vec::new(),
10235            },
10236            sem: Arc::new(Semaphore::new(1)),
10237            pause: Pause::new(),
10238            interrupt: Pause::new(),
10239        };
10240
10241        runner.merge().await.expect("merge");
10242
10243        assert!(
10244            runner.state.merge.is_none(),
10245            "an empty gate must never be read as a passing one: {:?}",
10246            runner.state.merge
10247        );
10248    }
10249
10250    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
10251    /// commands configured and `merge.mode` is `none` (a review-only run).
10252    /// `gate` must still record a real attempt — zero commands, vacuously
10253    /// passed — rather than leaving `state.gate` empty in a way `merge`
10254    /// cannot tell apart from "never ran"; otherwise the run reaches
10255    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
10256    #[tokio::test]
10257    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10258        let tmp = tempfile::tempdir().expect("tempdir");
10259        let repo = tmp.path().join("repo");
10260        std::fs::create_dir_all(&repo).unwrap();
10261        init_repo(&repo);
10262
10263        // Default config: `verify.gate` empty, `merge.mode` is `none`.
10264        let config = Config::default();
10265
10266        let mut state = RunState::new(
10267            repo.clone(),
10268            "main".to_owned(),
10269            "deadbeef".to_owned(),
10270            "task".to_owned(),
10271            config,
10272        );
10273        state.candidates = vec![Candidate {
10274            index: 0,
10275            label: 'A',
10276            agent: "alpha".to_owned(),
10277            branch: "does-not-exist".to_owned(),
10278            worktree: repo.clone(),
10279            summary: String::new(),
10280            stat: String::new(),
10281            files: 0,
10282            commits: 0,
10283            empty: false,
10284            failed: None,
10285            verified_noop: None,
10286            duration_ms: 0,
10287            folded: false,
10288        }];
10289        state.tally = Some(Tally {
10290            first_choice: BTreeMap::from([('A', 1)]),
10291            borda: BTreeMap::new(),
10292            winner: 'A',
10293            rankings: 1,
10294            unanimous_initial: true,
10295            deliberated: false,
10296            changed_votes: 0,
10297            unanimous_final: true,
10298            tie_break: None,
10299            judges: 0,
10300            present: 0,
10301            quorum: 0,
10302            met_quorum: true,
10303            uncontested: Some("only candidate A produced a change".to_owned()),
10304        });
10305        state.reviews = vec![ReviewRound {
10306            round: 1,
10307            head: "deadbeef".to_owned(),
10308            verified_head: None,
10309            verified_at: None,
10310            reviews: Vec::new(),
10311            e2e: Vec::new(),
10312            fix: None,
10313            blocking: 0,
10314            answered: 0,
10315            expected: 0,
10316            clean: true,
10317            verify_retried: false,
10318            e2e_deferred: false,
10319            e2e_defer_reason: None,
10320            progressed: false,
10321            vote_split: false,
10322            reconsideration: Vec::new(),
10323            verdict: None,
10324        }];
10325
10326        let mut runner = Runner {
10327            state,
10328            roles: ResolvedRoles {
10329                implementers: Vec::new(),
10330                judges: Vec::new(),
10331                reviewers: Vec::new(),
10332                fixer: None,
10333                conductor: conductor(),
10334                implementer_roster: Vec::new(),
10335                judge_roster: Vec::new(),
10336                reviewer_roster: Vec::new(),
10337            },
10338            sem: Arc::new(Semaphore::new(1)),
10339            pause: Pause::new(),
10340            interrupt: Pause::new(),
10341        };
10342
10343        runner.gate().await.expect("gate");
10344        assert!(
10345            runner.state.gate_ran,
10346            "zero configured commands is still a real attempt, not an unrun gate"
10347        );
10348        assert!(runner.state.gate.is_empty());
10349        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10350        assert_ne!(
10351            runner.state.status,
10352            RunStatus::Blocked,
10353            "a gate with nothing to check must not read as failed"
10354        );
10355
10356        runner.merge().await.expect("merge");
10357        assert_eq!(
10358            runner.state.status,
10359            RunStatus::Ready,
10360            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10361        );
10362    }
10363
10364    /// `Config::cache_dir` is derived from `verify.e2e` as well as
10365    /// `verify.gate` (so the e2e leg and the final gate never build against
10366    /// different directories). With zero `verify.gate` commands but a
10367    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
10368    /// that lease before discovering it had nothing to run - so a repo with
10369    /// no gate commands could come back `resource_blocked` (and therefore
10370    /// still `gate_ran == false`) on nothing but an unrelated run holding the
10371    /// cache, exactly the contention this run's own zero commands could
10372    /// never have touched. `gate` must recognise there is nothing to check
10373    /// before it ever asks for the lease.
10374    #[tokio::test]
10375    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10376        crate::run::pin_test_home();
10377        let home = crate::run::home();
10378
10379        let tmp = tempfile::tempdir().expect("tempdir");
10380        let repo = tmp.path().join("repo");
10381        std::fs::create_dir_all(&repo).unwrap();
10382        init_repo(&repo);
10383        // Unique to this test, so holding its lease cannot collide with
10384        // another test sharing the same process-wide `home`.
10385        let cache_dir = tmp.path().join("target");
10386
10387        let mut config = Config::default();
10388        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10389        // `verify.gate` stays empty (the default). Bounded so a regression
10390        // that does start waiting fails the test in seconds, not hangs it.
10391        config.graph.timeout_verify = Some(2);
10392
10393        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10394        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10395            .expect("no io error acquiring directly")
10396        {
10397            crate::cache::AcquireOutcome::Acquired(g) => g,
10398            crate::cache::AcquireOutcome::Busy(b) => {
10399                panic!("expected the direct acquire to win the lease first: {b:?}")
10400            }
10401        };
10402
10403        let mut state = RunState::new(
10404            repo.clone(),
10405            "main".to_owned(),
10406            "deadbeef".to_owned(),
10407            "task".to_owned(),
10408            config,
10409        );
10410        state.candidates = vec![Candidate {
10411            index: 0,
10412            label: 'A',
10413            agent: "alpha".to_owned(),
10414            branch: "does-not-exist".to_owned(),
10415            worktree: repo.clone(),
10416            summary: String::new(),
10417            stat: String::new(),
10418            files: 0,
10419            commits: 0,
10420            empty: false,
10421            failed: None,
10422            verified_noop: None,
10423            duration_ms: 0,
10424            folded: false,
10425        }];
10426        state.tally = Some(Tally {
10427            first_choice: BTreeMap::from([('A', 1)]),
10428            borda: BTreeMap::new(),
10429            winner: 'A',
10430            rankings: 1,
10431            unanimous_initial: true,
10432            deliberated: false,
10433            changed_votes: 0,
10434            unanimous_final: true,
10435            tie_break: None,
10436            judges: 0,
10437            present: 0,
10438            quorum: 0,
10439            met_quorum: true,
10440            uncontested: Some("only candidate A produced a change".to_owned()),
10441        });
10442        state.reviews = vec![ReviewRound {
10443            round: 1,
10444            head: "deadbeef".to_owned(),
10445            verified_head: None,
10446            verified_at: None,
10447            reviews: Vec::new(),
10448            e2e: Vec::new(),
10449            fix: None,
10450            blocking: 0,
10451            answered: 0,
10452            expected: 0,
10453            clean: true,
10454            verify_retried: false,
10455            e2e_deferred: false,
10456            e2e_defer_reason: None,
10457            progressed: false,
10458            vote_split: false,
10459            reconsideration: Vec::new(),
10460            verdict: None,
10461        }];
10462
10463        let mut runner = Runner {
10464            state,
10465            roles: ResolvedRoles {
10466                implementers: Vec::new(),
10467                judges: Vec::new(),
10468                reviewers: Vec::new(),
10469                fixer: None,
10470                conductor: conductor(),
10471                implementer_roster: Vec::new(),
10472                judge_roster: Vec::new(),
10473                reviewer_roster: Vec::new(),
10474            },
10475            sem: Arc::new(Semaphore::new(1)),
10476            pause: Pause::new(),
10477            interrupt: Pause::new(),
10478        };
10479
10480        let started = std::time::Instant::now();
10481        runner.gate().await.expect("gate");
10482        assert!(
10483            started.elapsed() < Duration::from_secs(1),
10484            "a gate with nothing to run must never wait on a lease it never needed"
10485        );
10486        assert!(
10487            runner.state.gate_ran,
10488            "zero commands is still a real, immediate attempt"
10489        );
10490        assert!(runner.state.gate.is_empty());
10491        assert_ne!(
10492            runner.state.status,
10493            RunStatus::Blocked,
10494            "must not read as resource-blocked on a lease it never asked for"
10495        );
10496    }
10497
10498    /// The addendum's second gap: a `verify.gate` command running for real
10499    /// wall-clock time had nothing at all to show for it in `active` before
10500    /// `run_commands` learned to record it — a run could sit in `Gating` for
10501    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
10502    /// what was actually happening. Proven with a genuinely still-running
10503    /// command, not just a before/after check on the final state: a poller
10504    /// task reads the same `run.json` `gate()` is writing, the same way the
10505    /// phone or `magi show` would, while the shell command is still blocked
10506    /// on its own release marker.
10507    #[tokio::test]
10508    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10509        crate::run::pin_test_home();
10510
10511        let tmp = tempfile::tempdir().expect("tempdir");
10512        let repo = tmp.path().join("repo");
10513        std::fs::create_dir_all(&repo).unwrap();
10514        init_repo(&repo);
10515
10516        let mut config = Config::default();
10517        config.verify.gate = vec![
10518            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10519             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10520                .to_owned(),
10521        ];
10522
10523        let mut state = RunState::new(
10524            repo.clone(),
10525            "main".to_owned(),
10526            "deadbeef".to_owned(),
10527            "task".to_owned(),
10528            config,
10529        );
10530        let run_id = state.id.clone();
10531        state.candidates = vec![Candidate {
10532            index: 0,
10533            label: 'A',
10534            agent: "alpha".to_owned(),
10535            branch: "does-not-exist".to_owned(),
10536            worktree: repo.clone(),
10537            summary: String::new(),
10538            stat: String::new(),
10539            files: 0,
10540            commits: 0,
10541            empty: false,
10542            failed: None,
10543            verified_noop: None,
10544            duration_ms: 0,
10545            folded: false,
10546        }];
10547        state.tally = Some(Tally {
10548            first_choice: BTreeMap::from([('A', 1)]),
10549            borda: BTreeMap::new(),
10550            winner: 'A',
10551            rankings: 1,
10552            unanimous_initial: true,
10553            deliberated: false,
10554            changed_votes: 0,
10555            unanimous_final: true,
10556            tie_break: None,
10557            judges: 0,
10558            present: 0,
10559            quorum: 0,
10560            met_quorum: true,
10561            uncontested: Some("only candidate A produced a change".to_owned()),
10562        });
10563        state.reviews = vec![ReviewRound {
10564            round: 1,
10565            head: "deadbeef".to_owned(),
10566            verified_head: None,
10567            verified_at: None,
10568            reviews: Vec::new(),
10569            e2e: Vec::new(),
10570            fix: None,
10571            blocking: 0,
10572            answered: 0,
10573            expected: 0,
10574            clean: true,
10575            verify_retried: false,
10576            e2e_deferred: false,
10577            e2e_defer_reason: None,
10578            progressed: false,
10579            vote_split: false,
10580            reconsideration: Vec::new(),
10581            verdict: None,
10582        }];
10583
10584        let mut runner = Runner {
10585            state,
10586            roles: ResolvedRoles {
10587                implementers: Vec::new(),
10588                judges: Vec::new(),
10589                reviewers: Vec::new(),
10590                fixer: None,
10591                conductor: conductor(),
10592                implementer_roster: Vec::new(),
10593                judge_roster: Vec::new(),
10594                reviewer_roster: Vec::new(),
10595            },
10596            sem: Arc::new(Semaphore::new(1)),
10597            pause: Pause::new(),
10598            interrupt: Pause::new(),
10599        };
10600
10601        let started_marker = repo.join("started.marker");
10602        let release_marker = repo.join("release.marker");
10603        let poller = tokio::spawn(async move {
10604            // Bounded so a regression that never records the task entry
10605            // fails this test in seconds instead of hanging the suite —
10606            // the same shape `a_park_requested_while_a_seat_is_mid_call_
10607            // does_not_cut_it_short` uses for the same reason.
10608            for _ in 0..100 {
10609                if started_marker.exists()
10610                    && let Ok(s) = crate::run::RunState::load(&run_id)
10611                    && let Some(a) = s.active.get("gate")
10612                {
10613                    std::fs::write(&release_marker, b"go").expect("release marker");
10614                    return Some(a.clone());
10615                }
10616                tokio::time::sleep(Duration::from_millis(50)).await;
10617            }
10618            None
10619        });
10620
10621        runner.gate().await.expect("gate");
10622        let captured = poller.await.expect("poller task");
10623        let captured = captured.expect(
10624            "the poller never saw a `gate` task entry in run.json while the command was \
10625             still blocked on its own release marker",
10626        );
10627
10628        assert_eq!(captured.task.as_deref(), Some("gate"));
10629        assert_eq!(captured.node, "gate");
10630        assert_eq!(captured.index, Some(1));
10631        assert_eq!(captured.total, Some(1));
10632        assert!(
10633            captured
10634                .command
10635                .as_deref()
10636                .is_some_and(|c| c.contains("started.marker")),
10637            "{captured:?}"
10638        );
10639
10640        assert!(
10641            runner.state.active.is_empty(),
10642            "the entry must be cleared once the command actually finished: {:?}",
10643            runner.state.active
10644        );
10645        assert!(runner.state.gate_ran);
10646        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10647    }
10648
10649    /// The hand-off over a blocking finding a reviewer rejected on leaves a
10650    /// record for `land`; one with only a Minor, or no reject, leaves none.
10651    #[tokio::test]
10652    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10653        use crate::verdict::{Finding, ReviewVote, Severity};
10654        crate::run::pin_test_home();
10655        let tmp = tempfile::tempdir().expect("tempdir");
10656        let repo = tmp.path().join("repo");
10657        std::fs::create_dir_all(&repo).unwrap();
10658        init_repo(&repo);
10659
10660        for (severity, vote, expect) in [
10661            (Severity::Major, ReviewVote::Reject, true),
10662            (Severity::Minor, ReviewVote::Reject, false),
10663            (Severity::Major, ReviewVote::Approve, false),
10664        ] {
10665            let mut round = review_round(false, 1, 1, 1, false, true);
10666            round.reviews = vec![ReviewRecord {
10667                reviewer: 1,
10668                agent: "alpha".to_owned(),
10669                summary: String::new(),
10670                findings: vec![Finding {
10671                    id: "R1-1-1".to_owned(),
10672                    severity,
10673                    file: None,
10674                    line: None,
10675                    title: "t".to_owned(),
10676                    detail: String::new(),
10677                }],
10678                vote: Some(vote),
10679                failed: None,
10680                duration_ms: 0,
10681                attempts: 0,
10682            }];
10683            let mut state = RunState::new(
10684                repo.clone(),
10685                "main".to_owned(),
10686                "deadbeef".to_owned(),
10687                "task".to_owned(),
10688                Config::default(),
10689            );
10690            state.reviews = vec![round];
10691            let mut runner = Runner {
10692                state,
10693                roles: ResolvedRoles {
10694                    implementers: Vec::new(),
10695                    judges: Vec::new(),
10696                    reviewers: Vec::new(),
10697                    fixer: None,
10698                    conductor: conductor(),
10699                    implementer_roster: Vec::new(),
10700                    judge_roster: Vec::new(),
10701                    reviewer_roster: Vec::new(),
10702                },
10703                sem: Arc::new(Semaphore::new(1)),
10704                pause: Pause::new(),
10705                interrupt: Pause::new(),
10706            };
10707            let shell = runner.state.config.shell();
10708            runner
10709                .stop_reviewing("round budget spent", &shell, &repo)
10710                .await
10711                .expect("stop_reviewing");
10712            assert_eq!(runner.state.status, RunStatus::Gating);
10713            assert_eq!(
10714                runner.state.contested_handoff.is_some(),
10715                expect,
10716                "{severity:?} + {vote:?}"
10717            );
10718        }
10719    }
10720
10721    /// The shape the incident this whole fix responds to actually had: the
10722    /// round budget spent, the last round's own e2e blocked on the shared
10723    /// build cache (held here by a live pid — this test process — exactly
10724    /// `cache`'s own unit tests' pattern for "another owner, still alive"
10725    /// without forking a process). `stop_reviewing` must retry it — not
10726    /// silently leave the round looking untouched (the catch-up-only half of
10727    /// the bug), and not read the contention as a red `e2e` and block the
10728    /// run on it (the other half). Called directly, the same way
10729    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
10730    /// above exercises `gate`, so this never needs a real cargo build to
10731    /// reach: the lease is never released, so `with_cache_lease` never gets
10732    /// past acquiring it into anything that would need a real workspace.
10733    #[tokio::test]
10734    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10735        crate::run::pin_test_home();
10736        let home = crate::run::home();
10737
10738        let tmp = tempfile::tempdir().expect("tempdir");
10739        let repo = tmp.path().join("repo");
10740        std::fs::create_dir_all(&repo).unwrap();
10741        init_repo(&repo);
10742        let head = crate::git::rev_parse(&repo, "HEAD")
10743            .await
10744            .expect("rev-parse");
10745        // Unique to this test, so holding its lease cannot collide with
10746        // another test sharing the same process-wide `home`.
10747        let cache_dir = tmp.path().join("target");
10748
10749        let mut config = Config::default();
10750        config.verify.e2e = vec![format!(
10751            "CARGO_TARGET_DIR='{}' test -f README.md",
10752            cache_dir.display()
10753        )];
10754        config.graph.review_rounds = 1;
10755        // Bounded so a regression that does start waiting fails the test in
10756        // seconds, not hangs it.
10757        config.graph.timeout_verify = Some(2);
10758
10759        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10760        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10761            .expect("no io error acquiring directly")
10762        {
10763            crate::cache::AcquireOutcome::Acquired(g) => g,
10764            crate::cache::AcquireOutcome::Busy(b) => {
10765                panic!("expected the direct acquire to win the lease first: {b:?}")
10766            }
10767        };
10768
10769        let mut state = RunState::new(
10770            repo.clone(),
10771            "main".to_owned(),
10772            head.clone(),
10773            "task".to_owned(),
10774            config,
10775        );
10776        state.candidates = vec![Candidate {
10777            index: 0,
10778            label: 'A',
10779            agent: "alpha".to_owned(),
10780            branch: "does-not-exist".to_owned(),
10781            worktree: repo.clone(),
10782            summary: String::new(),
10783            stat: String::new(),
10784            files: 0,
10785            commits: 0,
10786            empty: false,
10787            failed: None,
10788            verified_noop: None,
10789            duration_ms: 0,
10790            folded: false,
10791        }];
10792        state.tally = Some(Tally {
10793            first_choice: BTreeMap::from([('A', 1)]),
10794            borda: BTreeMap::new(),
10795            winner: 'A',
10796            rankings: 1,
10797            unanimous_initial: true,
10798            deliberated: false,
10799            changed_votes: 0,
10800            unanimous_final: true,
10801            tie_break: None,
10802            judges: 0,
10803            present: 0,
10804            quorum: 0,
10805            met_quorum: true,
10806            uncontested: Some("only candidate A produced a change".to_owned()),
10807        });
10808        // The round budget's last round, deferred: `needs_catchup_run`'s
10809        // other trigger. `stop_reviewing`'s retry machinery must treat this
10810        // exactly like a resource-blocked attempt once it actually runs.
10811        state.reviews = vec![ReviewRound {
10812            round: 1,
10813            head: head.clone(),
10814            verified_head: None,
10815            verified_at: None,
10816            reviews: Vec::new(),
10817            e2e: Vec::new(),
10818            fix: None,
10819            blocking: 1,
10820            answered: 1,
10821            expected: 1,
10822            clean: false,
10823            verify_retried: false,
10824            e2e_deferred: true,
10825            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10826            progressed: false,
10827            vote_split: false,
10828            reconsideration: Vec::new(),
10829            verdict: None,
10830        }];
10831
10832        let mut runner = Runner {
10833            state,
10834            roles: ResolvedRoles {
10835                implementers: Vec::new(),
10836                judges: Vec::new(),
10837                reviewers: Vec::new(),
10838                fixer: None,
10839                conductor: conductor(),
10840                implementer_roster: Vec::new(),
10841                judge_roster: Vec::new(),
10842                reviewer_roster: Vec::new(),
10843            },
10844            sem: Arc::new(Semaphore::new(1)),
10845            pause: Pause::new(),
10846            interrupt: Pause::new(),
10847        };
10848
10849        let shell = runner.state.config.shell();
10850        runner
10851            .stop_reviewing("round budget spent", &shell, &repo)
10852            .await
10853            .expect("stop_reviewing");
10854
10855        let last = runner.state.reviews.last().expect("round record");
10856        assert_eq!(
10857            last.e2e_status(),
10858            E2eStatus::ResourceBlocked,
10859            "the shared cache is still held; the attempt must read as blocked, not deferred or \
10860             failed: {last:?}"
10861        );
10862        assert_eq!(
10863            last.verified_head.as_deref(),
10864            Some(head.as_str()),
10865            "which commit this attempt targeted is known even though nothing finished checking \
10866             it"
10867        );
10868        let first_attempt_at = last
10869            .verified_at
10870            .expect("when this attempt ran is known too");
10871        assert_ne!(
10872            runner.state.status,
10873            RunStatus::Blocked,
10874            "contention is evidence about the machine, not the patch — it must not settle the \
10875             run as blocked: {:?}",
10876            runner.state.status
10877        );
10878        assert!(
10879            !runner
10880                .state
10881                .events
10882                .iter()
10883                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10884            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10885            runner.state.events
10886        );
10887
10888        // The cache is still held: a later reentry must retry the same
10889        // round's verification again — not leave it looking exactly as
10890        // untouched as the first blocked attempt, which is indistinguishable
10891        // from never having tried again at all.
10892        runner
10893            .stop_reviewing("round budget spent", &shell, &repo)
10894            .await
10895            .expect("stop_reviewing retry");
10896        assert_eq!(
10897            runner.state.reviews.len(),
10898            1,
10899            "no new round was started: {:?}",
10900            runner.state.reviews
10901        );
10902        let last = runner.state.reviews.last().expect("round record");
10903        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10904        assert!(
10905            last.verified_at.expect("still known") > first_attempt_at,
10906            "a second reentry must be a fresh attempt, not a stale copy of the first"
10907        );
10908        assert_ne!(runner.state.status, RunStatus::Blocked);
10909
10910        held.release();
10911    }
10912
10913    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
10914    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
10915    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
10916    /// own top-of-function fast path (`review_conclusion`) correctly reads
10917    /// this shape as `None` rather than guessing `Blocked`, and the loop's
10918    /// own `for` range is empty once the round budget is spent, so
10919    /// `review_loop` must retry the check itself rather than silently doing
10920    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
10921    /// above exercises directly, but through `review_loop`'s own entry point
10922    /// this time, proving the wiring between the two rather than just the
10923    /// retry logic in isolation.
10924    #[tokio::test]
10925    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10926        crate::run::pin_test_home();
10927        let home = crate::run::home();
10928
10929        let tmp = tempfile::tempdir().expect("tempdir");
10930        let repo = tmp.path().join("repo");
10931        std::fs::create_dir_all(&repo).unwrap();
10932        init_repo(&repo);
10933        let head = crate::git::rev_parse(&repo, "HEAD")
10934            .await
10935            .expect("rev-parse");
10936        let cache_dir = tmp.path().join("target");
10937
10938        let mut config = Config::default();
10939        config.verify.e2e = vec![format!(
10940            "CARGO_TARGET_DIR='{}' test -f README.md",
10941            cache_dir.display()
10942        )];
10943        config.graph.review_rounds = 1;
10944        config.graph.timeout_verify = Some(2);
10945
10946        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10947        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10948            .expect("no io error acquiring directly")
10949        {
10950            crate::cache::AcquireOutcome::Acquired(g) => g,
10951            crate::cache::AcquireOutcome::Busy(b) => {
10952                panic!("expected the direct acquire to win the lease first: {b:?}")
10953            }
10954        };
10955
10956        let mut state = RunState::new(
10957            repo.clone(),
10958            "main".to_owned(),
10959            head.clone(),
10960            "task".to_owned(),
10961            config,
10962        );
10963        state.candidates = vec![Candidate {
10964            index: 0,
10965            label: 'A',
10966            agent: "alpha".to_owned(),
10967            branch: "does-not-exist".to_owned(),
10968            worktree: repo.clone(),
10969            summary: String::new(),
10970            stat: String::new(),
10971            files: 0,
10972            commits: 0,
10973            empty: false,
10974            failed: None,
10975            verified_noop: None,
10976            duration_ms: 0,
10977            folded: false,
10978        }];
10979        state.tally = Some(Tally {
10980            first_choice: BTreeMap::from([('A', 1)]),
10981            borda: BTreeMap::new(),
10982            winner: 'A',
10983            rankings: 1,
10984            unanimous_initial: true,
10985            deliberated: false,
10986            changed_votes: 0,
10987            unanimous_final: true,
10988            tie_break: None,
10989            judges: 0,
10990            present: 0,
10991            quorum: 0,
10992            met_quorum: true,
10993            uncontested: Some("only candidate A produced a change".to_owned()),
10994        });
10995        // The exact shape a prior process's `stop_reviewing` would have left
10996        // on disk: the round budget's last round, a real attempt already
10997        // made and already resource-blocked.
10998        state.reviews = vec![ReviewRound {
10999            round: 1,
11000            head: head.clone(),
11001            verified_head: Some(head.clone()),
11002            verified_at: Some(jiff::Timestamp::now()),
11003            reviews: Vec::new(),
11004            e2e: vec![CommandOutcome {
11005                command: format!(
11006                    "CARGO_TARGET_DIR='{}' test -f README.md",
11007                    cache_dir.display()
11008                ),
11009                code: None,
11010                output_tail: "waiting for the shared build cache".to_owned(),
11011                duration_ms: 0,
11012                resource_blocked: true,
11013            }],
11014            fix: None,
11015            blocking: 1,
11016            answered: 1,
11017            expected: 1,
11018            clean: false,
11019            verify_retried: false,
11020            e2e_deferred: false,
11021            e2e_defer_reason: None,
11022            progressed: false,
11023            vote_split: false,
11024            reconsideration: Vec::new(),
11025            verdict: None,
11026        }];
11027
11028        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11029        let mut runner = Runner {
11030            state,
11031            roles: ResolvedRoles {
11032                implementers: Vec::new(),
11033                judges: Vec::new(),
11034                reviewers: Vec::new(),
11035                fixer: None,
11036                conductor: conductor(),
11037                implementer_roster: Vec::new(),
11038                judge_roster: Vec::new(),
11039                reviewer_roster: Vec::new(),
11040            },
11041            sem: Arc::new(Semaphore::new(1)),
11042            pause: Pause::new(),
11043            interrupt: Pause::new(),
11044        };
11045
11046        // The lease is still held throughout, so this reentry's own retry is
11047        // also contended — proving `review_loop` actually tried again (not
11048        // that it happened to succeed) is what the timestamp comparison
11049        // below is for.
11050        runner.review_loop().await.expect("review_loop");
11051
11052        assert_eq!(
11053            runner.state.reviews.len(),
11054            1,
11055            "no new round was started on top of the unresolved one: {:?}",
11056            runner.state.reviews
11057        );
11058        let last = &runner.state.reviews[0];
11059        assert_eq!(
11060            last.e2e_status(),
11061            E2eStatus::ResourceBlocked,
11062            "still contended: {last:?}"
11063        );
11064        assert!(
11065            last.verified_at.expect("still known") > first_attempt_at,
11066            "review_loop must have actually retried the check, not left it exactly as found"
11067        );
11068        assert_ne!(
11069            runner.state.status,
11070            RunStatus::Blocked,
11071            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11072            runner.state.status
11073        );
11074
11075        held.release();
11076    }
11077
11078    #[tokio::test]
11079    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11080        crate::run::pin_test_home();
11081        let tmp = tempfile::tempdir().expect("tempdir");
11082        let repo = tmp.path().join("repo");
11083        std::fs::create_dir_all(&repo).unwrap();
11084        init_repo(&repo);
11085
11086        let mut config = Config::default();
11087        config.merge.mode = MergeMode::Pr;
11088        config.graph.land = true;
11089        config.graph.land_approval = false;
11090
11091        let mut state = RunState::new(
11092            repo.clone(),
11093            "main".to_owned(),
11094            "deadbeef".to_owned(),
11095            "task".to_owned(),
11096            config,
11097        );
11098        state.candidates = vec![Candidate {
11099            index: 0,
11100            label: 'A',
11101            agent: "alpha".to_owned(),
11102            branch: "does-not-exist".to_owned(),
11103            worktree: repo.clone(),
11104            summary: String::new(),
11105            stat: String::new(),
11106            files: 0,
11107            commits: 0,
11108            empty: false,
11109            failed: None,
11110            verified_noop: None,
11111            duration_ms: 0,
11112            folded: false,
11113        }];
11114        state.tally = Some(Tally {
11115            first_choice: BTreeMap::from([('A', 1)]),
11116            borda: BTreeMap::new(),
11117            winner: 'A',
11118            rankings: 1,
11119            unanimous_initial: true,
11120            deliberated: false,
11121            changed_votes: 0,
11122            unanimous_final: true,
11123            tie_break: None,
11124            judges: 0,
11125            present: 0,
11126            quorum: 0,
11127            met_quorum: true,
11128            uncontested: Some("only candidate A produced a change".to_owned()),
11129        });
11130        state.reviews = vec![ReviewRound {
11131            round: 1,
11132            head: "deadbeef".to_owned(),
11133            verified_head: None,
11134            verified_at: None,
11135            reviews: Vec::new(),
11136            e2e: Vec::new(),
11137            fix: None,
11138            blocking: 0,
11139            answered: 0,
11140            expected: 0,
11141            clean: true,
11142            verify_retried: false,
11143            e2e_deferred: false,
11144            e2e_defer_reason: None,
11145            progressed: false,
11146            vote_split: false,
11147            reconsideration: Vec::new(),
11148            verdict: None,
11149        }];
11150        state.gate = vec![CommandOutcome {
11151            command: "test".to_owned(),
11152            code: Some(0),
11153            output_tail: String::new(),
11154            duration_ms: 0,
11155            resource_blocked: false,
11156        }];
11157        state.gate_ran = true;
11158        // A first pass through `merge` already pushed and opened this pull
11159        // request; `status` is `Landing` because a previous call into `land`
11160        // parked or was interrupted before it reached a terminal outcome.
11161        state.status = RunStatus::Landing;
11162        state.merge = Some(MergeOutcome {
11163            mode: MergeMode::Pr,
11164            ok: true,
11165            detail: "https://example.invalid/x/y/pull/1".to_owned(),
11166            empty: false,
11167        });
11168
11169        // The Landing-resume shortcut calls `run_land` directly rather than
11170        // through `merge`, which is exactly the call site that used to skip
11171        // `settle_questions` - see the fixture below.
11172        ask_test_home();
11173        let store = ask::Questions::open();
11174        let q = ask_open_question(&store, &state.id);
11175
11176        let mut runner = Runner {
11177            state,
11178            roles: ResolvedRoles {
11179                implementers: Vec::new(),
11180                judges: Vec::new(),
11181                reviewers: Vec::new(),
11182                fixer: None,
11183                conductor: conductor(),
11184                implementer_roster: Vec::new(),
11185                judge_roster: Vec::new(),
11186                reviewer_roster: Vec::new(),
11187            },
11188            sem: Arc::new(Semaphore::new(1)),
11189            pause: Pause::new(),
11190            interrupt: Pause::new(),
11191        };
11192
11193        // `execute`, not `merge` directly: the Landing-resume shortcut lives
11194        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
11195        // exactly because `review_loop` would otherwise clobber the marker
11196        // first.
11197        runner.execute().await.expect("execute");
11198
11199        assert_eq!(
11200            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11201            Some("https://example.invalid/x/y/pull/1"),
11202            "reentry must not push again or open a second pull request over the \
11203             one `land` is already watching"
11204        );
11205        assert_ne!(
11206            runner.state.status,
11207            RunStatus::Landing,
11208            "land could not actually reach the fake pull request, so it must \
11209             have given up rather than left the run silently parked forever"
11210        );
11211        // `land` could not reach the fake pull request, so it gave up into
11212        // `Blocked` - still resumable, so the question must not have been
11213        // swept just because this branch now also calls `settle_questions`.
11214        assert_eq!(runner.state.status, RunStatus::Blocked);
11215        assert!(
11216            store.get(&q.id).unwrap().status.open(),
11217            "Blocked is still alive; settle_questions must have been a no-op here"
11218        );
11219    }
11220
11221    fn state_with_round(round: ReviewRound) -> RunState {
11222        let mut s = RunState::new(
11223            PathBuf::from("/repo"),
11224            "main".to_owned(),
11225            "abc1234".to_owned(),
11226            "add retries".to_owned(),
11227            Config::default(),
11228        );
11229        s.reviews = vec![round];
11230        s
11231    }
11232
11233    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11234        crate::verdict::Finding {
11235            id: id.to_owned(),
11236            severity,
11237            file: None,
11238            line: None,
11239            title: title.to_owned(),
11240            detail: String::new(),
11241        }
11242    }
11243
11244    #[test]
11245    fn pr_body_names_open_findings_and_declined_ones() {
11246        let round = ReviewRound {
11247            round: 2,
11248            head: "deadbee".to_owned(),
11249            verified_head: None,
11250            verified_at: None,
11251            reviews: vec![ReviewRecord {
11252                attempts: 0,
11253                reviewer: 1,
11254                agent: "alpha".to_owned(),
11255                summary: String::new(),
11256                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11257                vote: None,
11258                failed: None,
11259                duration_ms: 0,
11260            }],
11261            e2e: vec![CommandOutcome {
11262                command: "cargo test".to_owned(),
11263                code: Some(0),
11264                output_tail: String::new(),
11265                duration_ms: 0,
11266                resource_blocked: false,
11267            }],
11268            verify_retried: false,
11269            e2e_deferred: false,
11270            e2e_defer_reason: None,
11271            fix: Some(FixRecord {
11272                agent: "alpha".to_owned(),
11273                addressed: Vec::new(),
11274                rejected: vec![crate::verdict::Rejection {
11275                    id: "R1-1-1".to_owned(),
11276                    why: "not reachable from any caller".to_owned(),
11277                }],
11278                notes: String::new(),
11279                committed: true,
11280                failed: None,
11281                duration_ms: 0,
11282                continuation: None,
11283            }),
11284            blocking: 0,
11285            answered: 1,
11286            expected: 1,
11287            clean: false,
11288            progressed: true,
11289            vote_split: false,
11290            reconsideration: Vec::new(),
11291            verdict: None,
11292        };
11293        let state = state_with_round(round);
11294        let body = pr_message(&state, 'A').body;
11295
11296        assert!(body.contains("add retries"), "the task must still be there");
11297        assert!(body.contains("R2-1-1"), "{body}");
11298        assert!(body.contains("unused import"), "{body}");
11299        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11300        assert!(
11301            body.contains("not reachable from any caller"),
11302            "the reason it was declined: {body}"
11303        );
11304    }
11305
11306    #[test]
11307    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11308        let round = ReviewRound {
11309            round: 1,
11310            head: "deadbee".to_owned(),
11311            verified_head: None,
11312            verified_at: None,
11313            reviews: vec![ReviewRecord {
11314                attempts: 0,
11315                reviewer: 1,
11316                agent: "alpha".to_owned(),
11317                summary: String::new(),
11318                findings: Vec::new(),
11319                vote: None,
11320                failed: None,
11321                duration_ms: 0,
11322            }],
11323            e2e: Vec::new(),
11324            verify_retried: false,
11325            e2e_deferred: false,
11326            e2e_defer_reason: None,
11327            fix: None,
11328            blocking: 0,
11329            answered: 1,
11330            expected: 1,
11331            clean: true,
11332            progressed: false,
11333            vote_split: false,
11334            reconsideration: Vec::new(),
11335            verdict: None,
11336        };
11337        let state = state_with_round(round);
11338        let body = pr_message(&state, 'A').body;
11339        assert!(!body.contains("Open review findings"), "{body}");
11340        assert!(!body.contains("Declined"), "{body}");
11341    }
11342
11343    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11344        let mut state = RunState::new(
11345            PathBuf::from("/repo"),
11346            "main".to_owned(),
11347            "abc1234".to_owned(),
11348            instruction.to_owned(),
11349            Config::default(),
11350        );
11351        state.candidates.push(Candidate {
11352            index: 0,
11353            label: 'A',
11354            agent: "alpha".to_owned(),
11355            branch: "magi/x/A".to_owned(),
11356            worktree: PathBuf::from("/wt"),
11357            summary: summary.to_owned(),
11358            stat: String::new(),
11359            files: 1,
11360            commits: 1,
11361            empty: false,
11362            failed: None,
11363            verified_noop: None,
11364            folded: false,
11365            duration_ms: 0,
11366        });
11367        state
11368    }
11369
11370    fn review_state(subjects: &[&str]) -> RunState {
11371        let mut state = state_with_summary(
11372            "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",
11373            "",
11374        );
11375        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11376        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11377        state
11378    }
11379
11380    /// A branch rebased onto a main that moved past the recorded
11381    /// `base_commit` is titled from its own first commit, never main's.
11382    #[tokio::test]
11383    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11384        ask_test_home();
11385        let tmp = tempfile::tempdir().unwrap();
11386        let repo = tmp.path().join("repo");
11387        std::fs::create_dir_all(&repo).unwrap();
11388        init_repo(&repo);
11389        let origin = tmp.path().join("origin.git");
11390        let g = |dir: &Path, args: &[&str]| {
11391            let out = std::process::Command::new("git")
11392                .args(args)
11393                .current_dir(dir)
11394                .quiet()
11395                .output()
11396                .expect("spawn git");
11397            assert!(
11398                out.status.success(),
11399                "git {args:?}: {}",
11400                String::from_utf8_lossy(&out.stderr)
11401            );
11402        };
11403        g(
11404            tmp.path(),
11405            &[
11406                "clone",
11407                "--bare",
11408                "-q",
11409                repo.to_str().unwrap(),
11410                origin.to_str().unwrap(),
11411            ],
11412        );
11413        g(
11414            &repo,
11415            &["remote", "add", "origin", origin.to_str().unwrap()],
11416        );
11417        let c1 = git::rev_parse(&repo, "main").await.unwrap();
11418
11419        // Main moves on; the branch is built on top of the new main.
11420        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11421        g(&repo, &["add", "-A"]);
11422        g(
11423            &repo,
11424            &["commit", "-q", "-m", "chore(deps): update a crate"],
11425        );
11426        g(&repo, &["push", "-q", "origin", "main"]);
11427        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11428        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11429        g(&repo, &["add", "-A"]);
11430        g(
11431            &repo,
11432            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11433        );
11434        g(&repo, &["checkout", "-q", "main"]);
11435
11436        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11437        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11438        // Without a readable tracking ref the recorded base's merge base is used.
11439        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11440        assert_eq!(fallback, c1);
11441
11442        let mut state = review_state(&[
11443            "chore(deps): update a crate",
11444            "fix(daemon): apply a chosen action",
11445        ]);
11446        state.repo = repo.clone();
11447        state.base_branch = "main".to_owned();
11448        state.base_commit = c1;
11449        refresh_reviewed_commits(&mut state, "feat/own").await;
11450        assert_eq!(
11451            state.reviewed_commits,
11452            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11453        );
11454        assert_eq!(
11455            review_title(&state).as_deref(),
11456            Some("fix(daemon): apply a chosen action")
11457        );
11458        assert_eq!(
11459            leaked_subjects(&state, "feat/own").await,
11460            Some(vec!["chore(deps): update a crate".to_owned()])
11461        );
11462        // A stale tracking ref is still used when the fetch fails, but the
11463        // leak list is withheld.
11464        state.config.merge.remote = "nowhere".to_owned();
11465        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11466    }
11467
11468    #[test]
11469    fn pr_message_review_single_commit_uses_its_subject() {
11470        let state = review_state(&["feat(nats): per-role user"]);
11471        let m = pr_message(&state, 'A');
11472        assert_eq!(m.title, "feat(nats): per-role user");
11473        assert!(!m.body.contains("Review the work already"), "{}", m.body);
11474        assert!(m.body.contains("## Commits under review"), "{}", m.body);
11475    }
11476
11477    #[test]
11478    fn pr_message_review_multi_commit_takes_the_oldest() {
11479        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11480        let m = pr_message(&state, 'A');
11481        assert_eq!(m.title, "feat: the change");
11482        for s in ["feat: the change", "fix: typo", "fix: again"] {
11483            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11484        }
11485    }
11486
11487    #[test]
11488    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11489        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11490            let mut state = review_state(&[first, "fix: later fixup"]);
11491            state.candidates[0].branch = "機能/ログイン".to_owned();
11492            let m = pr_message(&state, 'A');
11493            assert!(
11494                m.title.starts_with("chore: land candidate A of run"),
11495                "{}",
11496                m.title
11497            );
11498        }
11499    }
11500
11501    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11502        BranchFacts {
11503            commits: commits
11504                .iter()
11505                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11506                .collect(),
11507            stat: stat.to_owned(),
11508        }
11509    }
11510
11511    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11512        let mut r = review_round(true, 0, 1, 1, true, true);
11513        r.round = round;
11514        r.fix = notes.map(|n| FixRecord {
11515            agent: "fixer".to_owned(),
11516            addressed: Vec::new(),
11517            rejected: Vec::new(),
11518            notes: n.to_owned(),
11519            committed: true,
11520            failed: None,
11521            duration_ms: 0,
11522            continuation: None,
11523        });
11524        r
11525    }
11526
11527    #[test]
11528    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11529        let state = review_state(&["ignored"]);
11530        let f = facts(
11531            &[
11532                (
11533                    "fix(login): resolve PATH on macOS",
11534                    "Login shells skip rc files.",
11535                ),
11536                ("fix: address review", ""),
11537            ],
11538            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11539        );
11540        let m = pr_message_with(&state, 'A', Some(&f));
11541        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11542        assert!(
11543            m.body
11544                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
11545            "{}",
11546            m.body
11547        );
11548        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11549        assert!(m.body.contains("## Diff stat"), "{}", m.body);
11550        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11551        for banned in [
11552            "Review the work already",
11553            "no task statement",
11554            "Original task",
11555        ] {
11556            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11557        }
11558        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11559    }
11560
11561    #[test]
11562    fn pr_message_review_truncates_a_huge_first_commit_body() {
11563        let state = review_state(&["ignored"]);
11564        let f = facts(
11565            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11566            "s",
11567        );
11568        let m = pr_message_with(&state, 'A', Some(&f));
11569        assert!(m.body.len() < 30_000, "{}", m.body.len());
11570        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11571        assert!(
11572            m.body.contains("- ... 1 more commit(s)"),
11573            "{}",
11574            m.body.len()
11575        );
11576        assert!(m.body.ends_with("magi:candidate-a\n"));
11577    }
11578
11579    #[test]
11580    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11581        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11582        assert_eq!(m.title, "feat: x");
11583        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11584        assert!(!m.body.contains("Diff stat"), "{}", m.body);
11585        assert!(!m.body.contains("no task statement"), "{}", m.body);
11586    }
11587
11588    #[test]
11589    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11590        let mut state = review_state(&["日本語の件名"]);
11591        state.candidates[0].branch = "fix/macos-login-path".to_owned();
11592        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11593        state.candidates[0].branch = "機能/ログイン".to_owned();
11594        assert!(
11595            pr_message(&state, 'A')
11596                .title
11597                .starts_with("chore: land candidate A")
11598        );
11599    }
11600
11601    #[test]
11602    fn pr_message_review_fixes_survive_a_clean_final_round() {
11603        let mut state = review_state(&["feat: x"]);
11604        state.reviews = vec![
11605            round_with_notes(1, Some("handled the PATH case")),
11606            round_with_notes(2, None),
11607        ];
11608        let body = pr_message(&state, 'A').body;
11609        assert!(
11610            body.contains("## Review fixes\n\nhandled the PATH case\n"),
11611            "{body}"
11612        );
11613        assert!(!body.contains("### Round"), "{body}");
11614
11615        state.reviews = vec![
11616            round_with_notes(1, Some("first fix")),
11617            round_with_notes(2, Some("")),
11618            round_with_notes(3, Some("second fix")),
11619            round_with_notes(4, None),
11620        ];
11621        let body = pr_message(&state, 'A').body;
11622        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11623        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11624        assert!(!body.contains("### Round 2"), "{body}");
11625    }
11626
11627    #[test]
11628    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11629        let state = state_with_summary(
11630            "add retries to the client",
11631            "TITLE: feat: retries\n\nDid it.",
11632        );
11633        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11634        assert_eq!(m.title, "feat: retries");
11635        assert!(
11636            m.body.contains("<summary>Original task</summary>"),
11637            "{}",
11638            m.body
11639        );
11640        assert!(!m.body.contains("Commits under review"), "{}", m.body);
11641        assert!(
11642            m.body
11643                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11644            "{}",
11645            m.body
11646        );
11647    }
11648
11649    #[test]
11650    fn pr_message_review_bounds_a_long_english_subject() {
11651        let long = format!("feat: {}", "word ".repeat(100));
11652        let m = pr_message(&review_state(&[&long]), 'A');
11653        assert!(m.title.starts_with("feat: word"), "{}", m.title);
11654        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11655    }
11656
11657    #[test]
11658    fn pr_message_implementation_run_is_unchanged_by_review_support() {
11659        let state = state_with_summary("add retries\n\ndetails", "- did some things");
11660        let m = pr_message(&state, 'A');
11661        assert_eq!(m.title, "add retries");
11662        assert!(m.body.contains("<summary>Original task</summary>"));
11663        assert!(!m.body.contains("Commits under review"));
11664        assert_eq!(landing_subject_source(&state), state.instruction);
11665    }
11666
11667    #[test]
11668    fn review_run_squash_subject_is_the_change_not_the_prompt() {
11669        let state = review_state(&["feat: the change", "fix: typo"]);
11670        let source = landing_subject_source(&state);
11671        assert_eq!(land::merge_subject("", &source), "feat: the change");
11672        assert_eq!(
11673            land::merge_subject("magi: candidate A (uncommitted work)", &source),
11674            "feat: the change"
11675        );
11676        // An operator's rename still wins.
11677        assert_eq!(
11678            land::merge_subject("feat: renamed by hand", &source),
11679            "feat: renamed by hand"
11680        );
11681        let blank = review_state(&["日本語"]);
11682        assert!(
11683            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11684        );
11685    }
11686
11687    #[test]
11688    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11689        let state = review_state(&["feat: the change"]);
11690        let source = landing_subject_source(&state);
11691        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11692        assert_eq!(
11693            land::merge_subject(landing_title(&state, old), &source),
11694            "feat: the change"
11695        );
11696        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11697        let task = state_with_summary("add retries", "");
11698        assert_eq!(landing_title(&task, old), old);
11699    }
11700
11701    #[test]
11702    fn pr_message_describes_the_change_not_the_task() {
11703        let state = state_with_summary(
11704            "今回やってほしいこと: results projector を直す",
11705            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11706        );
11707        let m = pr_message(&state, 'A');
11708        assert_eq!(m.title, "fix(web): batch the runs list reads");
11709        assert!(
11710            m.body.starts_with("## Summary\n\n- reads run.json once"),
11711            "{}",
11712            m.body
11713        );
11714        assert!(!m.body.contains("TITLE:"), "{}", m.body);
11715        let task_at = m.body.find("今回やってほしいこと").unwrap();
11716        let details_at = m.body.find("<details>").unwrap();
11717        assert!(
11718            details_at < task_at,
11719            "the task lives inside <details>: {}",
11720            m.body
11721        );
11722        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11723        assert!(m.body.contains("magi:candidate-a"));
11724    }
11725
11726    #[test]
11727    fn pr_message_falls_back_to_the_task_without_a_title_line() {
11728        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11729        let m = pr_message(&state, 'A');
11730        assert_eq!(m.title, "add retries");
11731        assert!(
11732            m.body.contains("## Summary\n\n- did some things"),
11733            "{}",
11734            m.body
11735        );
11736
11737        let none = RunState::new(
11738            PathBuf::from("/repo"),
11739            "main".to_owned(),
11740            "abc1234".to_owned(),
11741            "add retries".to_owned(),
11742            Config::default(),
11743        );
11744        let m = pr_message(&none, 'A');
11745        assert_eq!(m.title, "add retries");
11746        assert!(!m.body.contains("## Summary"), "{}", m.body);
11747    }
11748
11749    #[test]
11750    fn pr_message_refuses_the_candidate_commit_subject() {
11751        for bad in [
11752            "TITLE: magi: candidate A (uncommitted work)",
11753            "TITLE: chore: stuff (uncommitted work)",
11754            "TITLE:   ",
11755        ] {
11756            let state = state_with_summary("add retries", bad);
11757            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11758        }
11759    }
11760
11761    #[test]
11762    fn pr_message_bounds_a_very_long_task_and_title() {
11763        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11764        let state = state_with_summary(&long, "- nothing");
11765        let m = pr_message(&state, 'A');
11766        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11767        assert!(!m.title.contains('\n'));
11768
11769        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11770        let m = pr_message(&state, 'A');
11771        assert!(m.title.starts_with("feat: "));
11772        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11773        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11774    }
11775
11776    fn long_title_of(instruction: &str) -> String {
11777        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11778    }
11779
11780    #[test]
11781    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11782        let first = "Make the landing path keep a readable title for long tasks";
11783        let line = format!(
11784            "{first}. {}",
11785            "And then keep going with more words ".repeat(20)
11786        );
11787        let t = long_title_of(&line);
11788        assert_eq!(t, first);
11789        assert!(!t.starts_with("chore: land"));
11790    }
11791
11792    #[test]
11793    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11794        let line = "word ".repeat(200);
11795        let t = long_title_of(&line);
11796        assert!(t.ends_with("word..."), "{t}");
11797        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11798    }
11799
11800    #[test]
11801    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11802        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11803            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11804        }
11805    }
11806
11807    #[test]
11808    fn pr_message_title_limit_is_exact() {
11809        let at = "a".repeat(PR_TITLE_MAX);
11810        assert_eq!(long_title_of(&at), at);
11811        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11812        assert!(over.ends_with("..."), "{over}");
11813        assert_eq!(over.chars().count(), PR_TITLE_MAX);
11814    }
11815
11816    #[test]
11817    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11818        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11819        let t = long_title_of(&line);
11820        assert!(t.ends_with("..."), "{t}");
11821        assert!(t.is_ascii(), "{t}");
11822    }
11823
11824    #[test]
11825    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11826        let line = format!(
11827            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11828            "plus more filler words ".repeat(20)
11829        );
11830        let t = long_title_of(&line);
11831        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11832    }
11833
11834    #[test]
11835    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11836        let line = format!("\n\n# {}", "title words ".repeat(40));
11837        let state = state_with_summary(&line, "- nothing");
11838        let m = pr_message(&state, 'A');
11839        assert!(m.title.starts_with("title words"), "{}", m.title);
11840        assert_eq!(
11841            land::merge_subject(&m.title, &landing_subject_source(&state)),
11842            m.title
11843        );
11844        // An operator's rename wins untouched.
11845        assert_eq!(
11846            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11847            "feat: renamed by hand"
11848        );
11849    }
11850
11851    #[tokio::test]
11852    async fn github_text_rewrite_is_bounded_and_falls_back() {
11853        crate::run::pin_test_home();
11854        for (reply, accepted) in [
11855            (
11856                "TITLE: fix: retries\nAdd retries for failed requests.",
11857                true,
11858            ),
11859            (
11860                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
11861                false,
11862            ),
11863            ("TITLE: fix: retries\nUse token=secret", false),
11864        ] {
11865            let dir = tempfile::tempdir().unwrap();
11866            let mut runner = runner_at(RunStatus::Gating);
11867            runner.state = state_with_summary(
11868                "add retries",
11869                "TITLE: fix: retries\n日本語の説明を書きます。",
11870            );
11871            runner.state.repo = dir.path().to_owned();
11872            runner.state.candidates[0].worktree = dir.path().to_owned();
11873            let mut author = spec("alpha");
11874            author.command = vec![
11875                "sh".into(),
11876                "-c".into(),
11877                "printf '%s' \"$REWRITE_REPLY\"".into(),
11878            ];
11879            author.env.insert("REWRITE_REPLY".into(), reply.into());
11880            runner.state.config.agents = vec![author];
11881            let winner = runner.state.candidates[0].clone();
11882            let message = runner.guarded_pr_message(&winner, None, true).await;
11883            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
11884            assert_eq!(
11885                message.body.contains("Add retries for failed requests."),
11886                accepted
11887            );
11888            assert_eq!(
11889                runner.state.seats["impl-A"].turns, 1,
11890                "only one rewrite invocation"
11891            );
11892            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
11893            if !accepted {
11894                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
11895                assert!(
11896                    message
11897                        .body
11898                        .contains(&format!("magi:run/{}", runner.state.id))
11899                );
11900            }
11901        }
11902    }
11903
11904    #[test]
11905    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11906        // What magi itself writes stays English under any configured language,
11907        // so a future localisation of these headings fails here. (The agents'
11908        // own text is also checked by the posting gate.)
11909        let mut state = state_with_summary(
11910            "add retries",
11911            "TITLE: fix(web): batch reads\n- reads run.json once",
11912        );
11913        state.config.graph.language = "ja".to_owned();
11914        let m = pr_message(&state, 'A');
11915        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
11916        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11917
11918        // The task is the operator's own text: it goes in untouched, and the
11919        // fallback title (no summary) may be in its language too.
11920        let task = "今回やってほしいこと: results projector を直す";
11921        let mut state = state_with_summary(task, "- no title line");
11922        state.config.graph.language = "ja".to_owned();
11923        let m = pr_message(&state, 'A');
11924        assert_eq!(
11925            m.title,
11926            format!("chore: land candidate A of run {}", state.id)
11927        );
11928        assert!(
11929            m.body.contains(&format!(
11930                "<summary>Original task</summary>\n\n{task}\n\n</details>"
11931            )),
11932            "{}",
11933            m.body
11934        );
11935    }
11936
11937    #[test]
11938    fn pr_message_scrubs_home_paths_and_addresses() {
11939        let state = state_with_summary(
11940            "fix it in /Users/someone/src/x",
11941            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11942        );
11943        let m = pr_message(&state, 'A');
11944        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11945            assert!(!m.body.contains(leak), "{}", m.body);
11946        }
11947        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11948    }
11949
11950    #[test]
11951    fn pr_message_survives_a_task_that_closes_details() {
11952        let state = state_with_summary("a </details> b", "TITLE: fix: x");
11953        let m = pr_message(&state, 'A');
11954        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11955    }
11956
11957    #[test]
11958    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11959        let cmd = manual_merge_command(
11960            MergeStyle::Squash,
11961            Path::new("/repo"),
11962            "b",
11963            "fix: \"quoted\" $(x) `y`\n\nbody",
11964        );
11965        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11966    }
11967
11968    #[test]
11969    fn manual_merge_command_matches_the_configured_style() {
11970        let repo = Path::new("/repo");
11971        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11972
11973        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11974        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11975
11976        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11977        assert_eq!(
11978            squash,
11979            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11980             \"Merge magi run 0832 (candidate A)\""
11981        );
11982
11983        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11984        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11985    }
11986
11987    #[test]
11988    fn a_nudge_gets_a_quarter_of_the_budget() {
11989        // The judge and implement budgets magi ships with.
11990        assert_eq!(retry_budget(secs(1200), true), secs(300));
11991        assert_eq!(retry_budget(secs(3600), true), secs(900));
11992    }
11993
11994    #[test]
11995    fn a_resent_prompt_keeps_the_whole_budget() {
11996        // The seat kept no context, so the retry is the original job again and
11997        // shortening it would only guarantee a second failure.
11998        assert_eq!(retry_budget(secs(1200), false), secs(1200));
11999        assert_eq!(retry_budget(secs(60), false), secs(60));
12000    }
12001
12002    #[test]
12003    fn the_floor_never_exceeds_the_original_budget() {
12004        // A short configured timeout must not be *raised* by the floor: the
12005        // operator asked for a bound, and a retry may not outlast the attempt
12006        // it is retrying.
12007        assert_eq!(retry_budget(secs(60), true), secs(60));
12008        assert_eq!(retry_budget(secs(480), true), secs(120));
12009        assert_eq!(retry_budget(secs(0), true), secs(0));
12010    }
12011
12012    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12013        agent::CommandEvidence {
12014            id: "item1".to_owned(),
12015            description: "cargo test".to_owned(),
12016            exit_code,
12017            result_summary: String::new(),
12018            source: "codex".to_owned(),
12019        }
12020    }
12021
12022    #[test]
12023    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12024        // No evidence is not the same fact as unconfirmed evidence: a
12025        // backend with no adapter, or a reply that ran no commands at all,
12026        // must not be misread as carrying a dangling job.
12027        assert!(!has_unconfirmed_command(&[]));
12028    }
12029
12030    #[test]
12031    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12032        // Deliberately not a check on the exit code's *value*: a fixer
12033        // legitimately runs something that fails mid-iteration before it
12034        // succeeds, and that must never by itself reopen a valid report.
12035        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12036        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12037        assert!(!has_unconfirmed_command(&[
12038            evidence(Some(0)),
12039            evidence(Some(101))
12040        ]));
12041    }
12042
12043    #[test]
12044    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12045        assert!(has_unconfirmed_command(&[
12046            evidence(Some(0)),
12047            evidence(None)
12048        ]));
12049    }
12050
12051    #[test]
12052    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12053        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12054        assert_eq!(
12055            verified_noop_claim(true, &[], text).as_deref(),
12056            Some("already fixed by b32cfc4, on main.")
12057        );
12058    }
12059
12060    #[test]
12061    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12062        // A timeout or a bad exit code reads as the ordinary loss it is,
12063        // whatever the reply's own prose claims.
12064        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12065        assert!(verified_noop_claim(false, &[], text).is_none());
12066    }
12067
12068    #[test]
12069    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12070        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12071        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12072        // A confirmed command alongside the marker is fine.
12073        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12074    }
12075
12076    #[test]
12077    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12078        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12079    }
12080
12081    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
12082    /// pair, in order, labelled A, B, C, ...
12083    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12084        runner.state.candidates = shape
12085            .iter()
12086            .enumerate()
12087            .map(|(i, &(empty, verified))| Candidate {
12088                index: i,
12089                label: (b'A' + i as u8) as char,
12090                agent: "sonnet".to_owned(),
12091                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12092                worktree: PathBuf::from(format!("/wt/{i}")),
12093                summary: String::new(),
12094                stat: String::new(),
12095                files: 0,
12096                commits: 0,
12097                empty,
12098                failed: None,
12099                verified_noop: verified.map(str::to_owned),
12100                duration_ms: 0,
12101                folded: false,
12102            })
12103            .collect();
12104    }
12105
12106    #[test]
12107    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12108        ask_test_home();
12109        let mut runner = runner_at(RunStatus::Implementing);
12110        set_candidates(
12111            &mut runner,
12112            &[
12113                (true, Some("already on main at b32cfc4")),
12114                (true, Some("same fix, see the existing test")),
12115            ],
12116        );
12117
12118        runner
12119            .after_implement()
12120            .expect("a verified no-op is not an error");
12121
12122        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12123    }
12124
12125    #[test]
12126    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12127        ask_test_home();
12128        let mut runner = runner_at(RunStatus::Implementing);
12129        // Candidate A declares a verified no-op; candidate B simply wrote
12130        // nothing and said nothing about why. One candidate's claim is not
12131        // the whole run's agreement.
12132        set_candidates(
12133            &mut runner,
12134            &[(true, Some("already on main at b32cfc4")), (true, None)],
12135        );
12136
12137        let err = runner
12138            .after_implement()
12139            .expect_err("an unverified empty candidate must still fail the run");
12140
12141        assert!(
12142            err.to_string().contains("no candidate produced a change"),
12143            "{err}"
12144        );
12145        assert_eq!(runner.state.status, RunStatus::Failed);
12146    }
12147
12148    #[test]
12149    fn after_implement_still_fails_an_ordinary_all_empty_run() {
12150        ask_test_home();
12151        let mut runner = runner_at(RunStatus::Implementing);
12152        set_candidates(&mut runner, &[(true, None), (true, None)]);
12153
12154        let err = runner
12155            .after_implement()
12156            .expect_err("no candidate declared anything; this is an ordinary failure");
12157
12158        assert!(
12159            err.to_string().contains("no candidate produced a change"),
12160            "{err}"
12161        );
12162        assert_eq!(runner.state.status, RunStatus::Failed);
12163    }
12164}