Skip to main content

magi/
graph.rs

1//! The competition graph.
2//!
3//! ```text
4//! prep ──► implement ×N ──► judge ×M (blind) ──► split? ──► deliberate ──► vote (private)
5//!                                                   │                          │
6//!                                                   └──── unanimous ───────────┤
7//!                                                                              ▼
8//!   merge ◄── gate ◄── review ×R + E2E, fix, repeat ◄── fold losers ◄──────── tally
9//! ```
10//!
11//! Every node persists before the next one starts, so a run can be resumed
12//! after a crash, a rate limit, or a reboot without re-spending the work that
13//! already landed.
14//!
15//! The design decision that matters most is *where the facilitator lives*.
16//! There is no moderator agent: magi assigns the labels, decides the
17//! presentation order, relays the transcript, and collects the final votes
18//! one-to-one. A moderator that never learns an author cannot leak one.
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36    ResolvedRoles,
37};
38use crate::git;
39use crate::land;
40use crate::proc::Quiet as _;
41use crate::prompt::{
42    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
43};
44use crate::queue;
45use crate::refs;
46use crate::run::{
47    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
48    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
49    JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
50    OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
51    RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
52};
53use crate::verdict::{
54    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
55    ReviewVote, Severity,
56};
57
58/// How much verification output is kept and fed back to the fixer.
59const OUTPUT_TAIL: usize = 8_000;
60
61/// Bytes of a failing command's output kept in an event, so the reason a run
62/// stopped is readable from the report without opening `run.json`.
63const EVENT_OUTPUT_TAIL: usize = 2_000;
64
65/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
66/// command's pid before releasing the build cache's lease.
67const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
68
69/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
70/// command's pid to actually exit before giving up and releasing anyway.
71///
72/// A timeout means the process was asked to die (`kill_on_drop`,
73/// `start_kill`), not that it already has — on Windows in particular that can
74/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
75/// the instant the command returns would let the very next acquirer (this
76/// run's own next round, another run's verification, the janitor's prune)
77/// start touching the same directory while it might still be writing to it,
78/// so this polls the actual pid — real confirmation, not a fixed guess —
79/// until it is gone or this ceiling is reached. It is still not full
80/// process-tree reaping: a grandchild the timed-out process spawned and that
81/// outlives it independently is invisible to a pid check, and continuing to
82/// observe and collect *that* stays a different piece of work with its own
83/// owner. Set generously because the common case returns early the moment
84/// the pid is confirmed gone, not because every timeout pays this in full.
85const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
86
87/// Consecutive review rounds with no tree progress (see
88/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
89/// instead of spending the rest of the round budget.
90///
91/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
92/// legitimately finds nothing left to change (its previous round's fix already
93/// covered it, and this round's reviewers re-raised only nits) looks the same
94/// as one that is spinning, for exactly one round. Two in a row is where the
95/// two stop being distinguishable, and a review round on this workload has
96/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
97/// third attempt at a tree that has not moved twice running is pure cost.
98/// This does not touch `review_rounds` itself, which stays the operator's
99/// call.
100pub(crate) const STAGNANT_LIMIT: usize = 2;
101
102/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
103/// a base that moved before giving up and leaving the run `Blocked` for a
104/// person.
105///
106/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
107/// that keeps moving faster than a run can catch it is not something more
108/// rebasing fixes, it is a person's call. Not the same *number as*
109/// `land_rounds` - this budget is spent before a pull request exists, land's
110/// after - but bounded for the identical reason, so it uses the same
111/// default. Counted across both call sites in [`Runner::finish_after_tally`]
112/// (once before review, once before the gate), because either one finding
113/// the base still moving is the same signal.
114const BASE_SYNC_ROUNDS: usize = 4;
115
116/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
117/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
118/// reply held no [`FixReport`].
119///
120/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
121/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
122/// "I'll pause here until the `cargo make check` background run reports
123/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
124/// No `FixReport` was ever collected from that seat, and the run moved on to
125/// the next review round regardless.
126///
127/// Bounded independently of `review_rounds` and `graph.retries`: this
128/// recovers one seat's missing report mid-round, not a new round of review or
129/// an ordinary parse retry, and must not itself become the unbounded wait the
130/// rest of this module exists to avoid.
131const MAX_FIX_CONTINUATIONS: usize = 2;
132
133/// One queued agent invocation.
134///
135/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
136/// CLI hung up on its own stream is asked again from the same job rather than
137/// rebuilt from scratch. See [`Runner::resume_undelivered`].
138#[derive(Clone)]
139struct SeatJob {
140    spec: AgentSpec,
141    seat: SeatState,
142    cwd: PathBuf,
143    prompt: String,
144    timeout: Duration,
145    allow_write: bool,
146    sessions: bool,
147    artifacts: PathBuf,
148    stem: String,
149    /// The prompt for a seat that has been handed to another roster agent
150    /// (a fresh session): everything the original seat would have
151    /// remembered. `None` when `prompt` already carries it, as the first
152    /// ranking and the implement prompt do. Never a resume-style prompt.
153    handover: Option<String>,
154}
155
156/// How the graph reads one agent invocation.
157///
158/// Quota is split out from an ordinary failure on purpose: a rate-limited call
159/// is known to fail again if retried now, so the retry loop must not spend an
160/// attempt on it. `Dropped` is split out for the opposite reason: unlike
161/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
162/// error JSON, never the agent's answer — a caller that matched only
163/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
164/// left to read that JSON as if it were usable output. `resume_undelivered`
165/// is the only caller that acts on it; everywhere else it is reported like an
166/// ordinary failure.
167enum AgentOutcome {
168    /// A usable output.
169    Ok(AgentOutput),
170    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
171    Quota(AgentOutput),
172    /// The CLI hung up on its own stream after billed work. See
173    /// [`agent::AgentOutput::work_undelivered`].
174    Dropped(AgentOutput),
175    /// Any other failure: a timeout, a bad exit code, an empty reply.
176    Failed(String),
177}
178
179/// A request to park the run at its next node boundary.
180///
181/// Cloning is how the request travels: the loop keeps one handle and hands a
182/// clone to each [`Runner`], and every clone points at the same flag. There
183/// is no channel because there is nothing to send - the only message is
184/// "park", it is idempotent, and a flag cannot be missed by a receiver that
185/// was not listening yet.
186///
187/// The boundary is what makes this cheap. Every node writes the run's state
188/// before the next one starts, and every node skips what is already recorded:
189/// `prep` returns early once candidates exist, `implement` asks only the seats
190/// with nothing on disk, `judge` returns early once judgements exist. So a
191/// parked run resumes into exactly the node it stopped before, and no agent
192/// work is thrown away. Killing the process mid-node, by contrast, loses
193/// whatever the seats in flight had not yet written - which for an implement
194/// wave is an hour of paid work.
195///
196/// A [`Runner`] watches two independent handles of this type - see
197/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
198/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
199/// clone covering the whole daemon's lifetime and is never asked to un-park,
200/// which is correct exactly because nothing is dispatched after it fires.
201/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
202/// that parks for an interrupted task must go on to run other tasks
203/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
204/// reusing the daemon-wide one.
205#[derive(Debug, Clone, Default)]
206pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
207
208impl Pause {
209    /// A pause nobody has asked for yet.
210    #[must_use]
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    /// Ask the run to park at its next node boundary. Idempotent.
216    pub fn park(&self) {
217        self.0.store(true, Ordering::SeqCst);
218    }
219
220    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
221    /// fold into the run's own `park` event - so an operator reading the run
222    /// later knows this was a deliberate interrupt rather than a shutdown or
223    /// a binary swap. The first reason recorded wins; a park already in
224    /// flight is not relabelled by a second, unrelated request.
225    pub fn park_because(&self, reason: impl Into<String>) {
226        let mut reason_guard = self
227            .1
228            .lock()
229            .unwrap_or_else(std::sync::PoisonError::into_inner);
230        if reason_guard.is_none() {
231            *reason_guard = Some(reason.into());
232        }
233        drop(reason_guard);
234        self.park();
235    }
236
237    /// Has a park been asked for?
238    #[must_use]
239    pub fn parked(&self) -> bool {
240        self.0.load(Ordering::SeqCst)
241    }
242
243    /// Why the park was asked for, when the caller used [`Pause::park_because`].
244    #[must_use]
245    pub fn reason(&self) -> Option<String> {
246        self.1
247            .lock()
248            .unwrap_or_else(std::sync::PoisonError::into_inner)
249            .clone()
250    }
251}
252
253/// Drives one run.
254pub struct Runner {
255    /// Run state; public so the CLI can report on it.
256    pub state: RunState,
257    roles: ResolvedRoles,
258    sem: Arc<Semaphore>,
259    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
260    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
261    /// this is never the same handle as `interrupt`.
262    pause: Pause,
263    /// Set when `magi serve`'s interrupt scheduler wants this specific run
264    /// parked at its next node boundary, to let a task marked
265    /// [`crate::queue::Task::interrupt`] run alone before this one carries
266    /// on. Unlike `pause`, a fresh, unshared handle per run - see
267    /// [`Runner::watch_interrupt`].
268    interrupt: Pause,
269}
270
271/// Where the branch's own commits start: its merge base with the base branch
272/// as the remote has it now, else with the recorded `base_commit`. A branch
273/// rebased onto a base that moved past `base_commit` would otherwise count
274/// the base's commits as its own under `base_commit..branch`.
275async fn review_base(
276    repo: &Path,
277    remote: &str,
278    base_branch: &str,
279    base_commit: &str,
280    branch: &str,
281) -> String {
282    review_base_checked(repo, remote, base_branch, base_commit, branch)
283        .await
284        .0
285}
286
287/// [`review_base`] plus whether the base was read from a freshly fetched
288/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
289/// never older than `base_commit`'s view of the base), but the answer is then
290/// not trusted to rewrite a pull request's title.
291async fn review_base_checked(
292    repo: &Path,
293    remote: &str,
294    base_branch: &str,
295    base_commit: &str,
296    branch: &str,
297) -> (String, bool) {
298    let tracking = format!("{remote}/{base_branch}");
299    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
300    if git::rev_exists(repo, &tracking).await
301        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
302        && !mb.is_empty()
303    {
304        return (mb, fresh);
305    }
306    let mb = git::merge_base(repo, base_commit, branch)
307        .await
308        .ok()
309        .filter(|mb| !mb.is_empty())
310        .unwrap_or_else(|| base_commit.to_owned());
311    (mb, false)
312}
313
314/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
315/// when git cannot say or finds nothing: a stale list is better than a wrong
316/// or empty one.
317pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
318    if !is_review_run(state) {
319        return;
320    }
321    let base = review_base(
322        &state.repo,
323        &state.config.merge.remote,
324        &state.base_branch,
325        &state.base_commit,
326        branch,
327    )
328    .await;
329    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
330        && !subjects.is_empty()
331        && state.reviewed_commits.as_ref() != Some(&subjects)
332    {
333        state.reviewed_commits = Some(subjects);
334        state.save().ok();
335    }
336}
337
338/// Subjects of the base's commits between the recorded start and the branch's
339/// merge base: what a stale `base_commit..branch` would have mistaken for the
340/// branch's own work.
341async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
342    let (base, trusted) = review_base_checked(
343        &state.repo,
344        &state.config.merge.remote,
345        &state.base_branch,
346        &state.base_commit,
347        branch,
348    )
349    .await;
350    if !trusted {
351        return None;
352    }
353    git::subjects(&state.repo, &state.base_commit, &base)
354        .await
355        .ok()
356}
357
358/// May an adopted pull request's title be replaced with `computed`? Only when
359/// it is empty, magi's own shape, or a base commit's subject that leaked in;
360/// a title a person wrote stays. Never when `computed` is itself a leak.
361fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
362    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
363    if is_leak(computed) {
364        return false;
365    }
366    let cur = current.trim();
367    cur.is_empty()
368        || cur.starts_with(REVIEW_PROMPT_OPENING)
369        || cur.starts_with("chore: land candidate")
370        || cur.starts_with("magi: candidate")
371        || is_leak(cur)
372}
373
374/// The commit a run branches from: the base branch as the remote has it.
375///
376/// Two failures this replaces. A run used to branch off `HEAD` and so refused
377/// to start on a dirty tree, which made `magi serve` decline every task for as
378/// long as the operator had work in progress - most of the time. Branching off
379/// the *local* base branch fixed that and introduced a worse one: `land` merges
380/// the winner on GitHub, nothing updates the local ref, and the next run
381/// branches off a base missing everything the previous runs landed. Two tasks
382/// in a row from a phone would have had the second silently re-implementing
383/// against stale code and opening a pull request that reverted the first.
384///
385/// Only refs move here - no checkout, no local branch, no merge - so it is safe
386/// with uncommitted work in the tree. A machine with no network still starts:
387/// the fetch may fail and the local tip is used with a warning, because
388/// refusing to run offline is a worse failure than running against a base the
389/// operator can see for themselves.
390///
391/// One function, called by both entry points. Two answers to "where does a run
392/// branch from" is the kind of drift nobody notices until a diff is wrong.
393/// Bring the local `branch` in line with `<remote>/<branch>` before a review
394/// checks it out.
395///
396/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
397/// anything other than plain `git push` from this checkout (a jj colocated
398/// workspace, another clone) moves only the remote-tracking ref - so the local
399/// one can be a stale placeholder. It moves only when local is behind the remote or is an
400/// empty placeholder that diverged from it; unpushed local work is kept, and a real
401/// divergence is refused rather than guessed at.
402async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
403    let tracking = format!("{remote}/{branch}");
404    let fetched = git::fetch(repo, remote, branch).await;
405    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
406    let local_exists = git::branch_exists(repo, branch).await?;
407    if !fresh {
408        if !local_exists {
409            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
410        }
411        tracing::warn!(
412            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
413        );
414        return Ok(());
415    }
416    let remote_sha = git::rev_parse(repo, &tracking).await?;
417    if !local_exists {
418        git::git(repo, &["branch", branch, &tracking]).await?;
419        return Ok(());
420    }
421    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
422    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
423        return Ok(());
424    }
425    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
426        // Diverged. `reconcile` settles it only when it can prove nothing is
427        // lost: a local tip that is the remote's change rebased is pushed over
428        // it (lease pinned to the tip read here), a tip whose every extra
429        // commit is empty is a placeholder the remote's work replaced, and
430        // anything else is two different changes - a question for a person.
431        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
432            .await?
433        {
434            crate::reconcile::Reconciliation::Pushed => {
435                tracing::warn!(
436                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
437                    short(&local_sha),
438                    short(&remote_sha)
439                );
440                return Ok(());
441            }
442            crate::reconcile::Reconciliation::Placeholder => {}
443            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
444        }
445    }
446    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
447    if !out.ok() {
448        bail!(
449            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
450            short(&local_sha),
451            short(&remote_sha),
452            out.stderr
453        );
454    }
455    tracing::warn!(
456        "local `{branch}` was stale: fast-forwarded {} -> {}",
457        short(&local_sha),
458        short(&remote_sha)
459    );
460    Ok(())
461}
462
463async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
464    let tracking = format!("{remote}/{base_branch}");
465    let fetched = git::fetch(repo, remote, base_branch).await;
466    if let Ok(out) = &fetched
467        && out.ok()
468        && git::rev_exists(repo, &tracking).await
469    {
470        return git::rev_parse(repo, &tracking).await;
471    }
472    let why = match &fetched {
473        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
474        Ok(_) => format!("{remote} has no {base_branch}"),
475        Err(e) => e.to_string(),
476    };
477    tracing::warn!(
478        "could not read {tracking} ({why}); branching off the local \
479         {base_branch} instead, which may be behind"
480    );
481    git::rev_parse(repo, base_branch).await.with_context(|| {
482        format!(
483            "cannot resolve `{base_branch}`; set [merge] base in magi.toml to a \
484             branch that exists"
485        )
486    })
487}
488
489/// Exclusive claim on one run's `magi fix` step, released on drop — including
490/// on an early return or a panic.
491///
492/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
493/// manual `magi fix` invocations against the same run are otherwise
494/// invisible to each other and would race to remove and recreate the same
495/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
496/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
497/// which is only ever reclaimed later, out of band, by
498/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
499/// `magi fix` invocation is not necessarily running under either of those, so
500/// nothing would ever sweep a lock a killed or crashed process left behind.
501/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
502/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
503/// lease use: an unreadable or unparsable pid, or a liveness query the
504/// platform cannot answer, reads as alive and the lock is left in place.
505struct FixClaim {
506    path: PathBuf,
507}
508
509impl FixClaim {
510    fn acquire(dir: &Path) -> Result<Self> {
511        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
512        let path = dir.join("fix.lock");
513        match Self::create(&path) {
514            Ok(claim) => Ok(claim),
515            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
516                if Self::reclaim_if_dead(&path) {
517                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
518                } else {
519                    bail!(
520                        "another `magi fix` is already running for this run ({} exists)",
521                        path.display()
522                    )
523                }
524            }
525            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
526        }
527    }
528
529    fn create(path: &Path) -> std::io::Result<Self> {
530        let mut f = std::fs::OpenOptions::new()
531            .write(true)
532            .create_new(true)
533            .open(path)?;
534        use std::io::Write as _;
535        // Read back by `reclaim_if_dead` on a later, stuck invocation.
536        writeln!(f, "{}", std::process::id())?;
537        Ok(Self {
538            path: path.to_owned(),
539        })
540    }
541
542    /// True if the lock named a process confirmed dead, in which case it was
543    /// also removed. Never true on an unreadable file, an unparsable pid, or
544    /// a liveness query the platform cannot answer — see this type's own doc.
545    fn reclaim_if_dead(path: &Path) -> bool {
546        let dead = std::fs::read_to_string(path)
547            .ok()
548            .and_then(|body| body.trim().parse::<u32>().ok())
549            .is_some_and(|pid| !crate::proc::pid_alive(pid));
550        dead && std::fs::remove_file(path).is_ok()
551    }
552}
553
554impl Drop for FixClaim {
555    fn drop(&mut self) {
556        let _ = std::fs::remove_file(&self.path);
557    }
558}
559
560impl Runner {
561    /// Start a fresh run against `repo`.
562    pub async fn start(
563        repo: &Path,
564        instruction: String,
565        config: Config,
566        origin: Origin,
567    ) -> Result<Self> {
568        Self::start_naming(repo, instruction, "", config, origin).await
569    }
570
571    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
572    /// searched for branch and commit references along with the instruction,
573    /// since a task may name the work it is about only in its title.
574    pub async fn start_naming(
575        repo: &Path,
576        instruction: String,
577        also_scan: &str,
578        config: Config,
579        origin: Origin,
580    ) -> Result<Self> {
581        let repo = git::toplevel(repo).await?;
582        let missing = agent::missing_programs(&config.agents);
583        if !missing.is_empty() {
584            bail!(
585                "these agent programs are not on PATH: {}. Fix the roster in \
586                 magi.toml or install them.",
587                missing.join(", ")
588            );
589        }
590        let base_branch = match config.merge.base.clone() {
591            Some(b) => b,
592            None => git::current_branch(&repo)
593                .await?
594                .context("HEAD is detached; set [merge] base in magi.toml")?,
595        };
596        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
597        // Still worth saying out loud. The operator's uncommitted work is not
598        // part of this run, and someone watching a candidate fail to use a
599        // change they just made deserves to know why.
600        if !git::is_clean(&repo).await? {
601            tracing::warn!(
602                "{} has uncommitted changes; they are not part of this run, \
603                 which branches off {base_branch} ({})",
604                repo.display(),
605                &base_commit[..base_commit.len().min(8)]
606            );
607        }
608        let roles = config.resolve_roles()?;
609        let max_parallel = config.graph.max_parallel.max(1);
610        // A task that points at work already in the repository starts from
611        // it; what the repository says about each reference is recorded.
612        let seeds = refs::resolve(
613            &repo,
614            &base_commit,
615            &config.merge.remote,
616            &format!("{also_scan}\n{instruction}"),
617        )
618        .await;
619        refs::plan(&repo, &seeds).await?;
620        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
621        // Recorded before the first save, so a crash right after minting
622        // cannot leave a run with no origin. Legibility only: nothing reads it
623        // to decide anything.
624        state.origin = Some(origin);
625        for seed in &seeds {
626            state.event(
627                "seed",
628                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
629            );
630        }
631        state.seeds = seeds;
632        state.event("start", format!("run {} created", state.id));
633        state.save()?;
634        Ok(Self {
635            state,
636            roles,
637            sem: Arc::new(Semaphore::new(max_parallel)),
638            pause: Pause::new(),
639            interrupt: Pause::new(),
640        })
641    }
642
643    /// Open a review-only run against work that already exists on `branch`.
644    ///
645    /// The expensive half of the graph is the implement wave — measured at
646    /// 111 and 134 internal tool-loop turns on this repository, against a
647    /// handful for a judge or a reviewer. The cheap half is worth running on
648    /// hand-written work too, and there was no way to reach it.
649    ///
650    /// No new state and no schema change are needed: a run with **one** viable
651    /// candidate and a tally already decided degrades `execute` to exactly
652    /// review → gate → merge, because `judge` skips a single-candidate field,
653    /// `deliberate` has fewer than two first choices to reconcile, `vote`
654    /// returns early, `tally` is already present and `fold_losers` has no
655    /// losers. Resuming such a run therefore does the right thing as well.
656    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
657        Self::review_taking_over(repo, branch, config, None, origin).await
658    }
659
660    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
661    /// at the same task still has `branch` checked out, its worktree is
662    /// released first if that is safe (see [`crate::handover`]), and the
663    /// review refuses with the reason if it is not. `None` is a hand-run
664    /// review: it has no earlier attempts, so only a worktree of a dead run
665    /// magi recorded itself can be released.
666    pub async fn review_taking_over(
667        repo: &Path,
668        branch: &str,
669        config: Config,
670        takeover: Option<crate::handover::Takeover>,
671        origin: Origin,
672    ) -> Result<Self> {
673        let repo = git::toplevel(repo).await?;
674        let missing = agent::missing_programs(&config.agents);
675        if !missing.is_empty() {
676            bail!(
677                "these agent programs are not on PATH: {}. Fix the roster in \
678                 magi.toml or install them.",
679                missing.join(", ")
680            );
681        }
682        let base_branch = match config.merge.base.clone() {
683            Some(b) => b,
684            None => git::current_branch(&repo)
685                .await?
686                .context("HEAD is detached; set [merge] base in magi.toml")?,
687        };
688        if base_branch == branch {
689            bail!("`{branch}` is the base branch; there is nothing to review against");
690        }
691        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
692
693        let roles = config.resolve_roles()?;
694        let max_parallel = config.graph.max_parallel.max(1);
695        let mut state = RunState::new(
696            repo.clone(),
697            base_branch,
698            base_commit.clone(),
699            String::new(),
700            config,
701        );
702        state.origin = Some(origin);
703
704        // Released before anything else touches the branch: a stale local
705        // branch is moved with `git branch -f`, which git refuses while an
706        // earlier attempt's worktree still has it checked out. Everything
707        // after this point that can fail puts the old run back.
708        // A hand-run review has no task, hence no earlier attempts, but a
709        // worktree of a dead run magi made may still be released.
710        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
711            earlier: Vec::new(),
712            home: crate::run::home(),
713            choice: None,
714        });
715        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
716        if let Some(released) = &released {
717            state.event(
718                "release",
719                format!(
720                    "took `{branch}` over from run {}: its worktree was released: {}",
721                    crate::run::short_of(&released.old_id),
722                    released.audit
723                ),
724            );
725        }
726        // The owner's answer to an earlier divergence question is applied
727        // here: after the release (git will not move a checked-out branch)
728        // and before the sync that would otherwise ask again.
729        if let Some(choice) = takeover.choice.as_ref()
730            && let Err(e) =
731                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
732                    .await
733        {
734            if let Some(released) = &released {
735                released.restore(&repo, branch).await;
736            }
737            return Err(e.context("applying the owner's answer about the diverged branch"));
738        }
739        let opened =
740            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
741        if opened.is_err()
742            && let Some(released) = &released
743        {
744            released.restore(&repo, branch).await;
745        }
746        opened
747    }
748
749    /// The half of [`Runner::review_taking_over`] that can fail after an
750    /// earlier attempt's worktree was released.
751    async fn open_review(
752        repo: &Path,
753        branch: &str,
754        mut state: RunState,
755        roles: ResolvedRoles,
756        max_parallel: usize,
757        base_commit: String,
758    ) -> Result<Self> {
759        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
760        // The commit subjects are the closest thing to a task statement that
761        // existing work carries, and the reviewers are told as much.
762        let start = review_base(
763            repo,
764            &state.config.merge.remote,
765            &state.base_branch,
766            &base_commit,
767            branch,
768        )
769        .await;
770        let log = git::log_oneline(repo, &start, branch)
771            .await
772            .unwrap_or_default();
773        let instruction = format!(
774            "Review the work already on branch `{branch}`. There is no task \
775             statement: what the change claims to do is whatever its commits \
776             say.\n\n{}",
777            if log.trim().is_empty() {
778                "(no commit messages)"
779            } else {
780                log.trim()
781            }
782        );
783        state.instruction = instruction;
784        state.reviewed_commits = Some(
785            git::subjects(repo, &start, branch)
786                .await
787                .unwrap_or_default(),
788        );
789
790        // An attached worktree, so the fixer's commits land on the branch under
791        // review rather than on a detached head nobody will look at again.
792        let worktree = state.worktree_root().join("under-review");
793        if let Some(parent) = worktree.parent() {
794            tokio::fs::create_dir_all(parent).await.ok();
795        }
796        let path = worktree.to_string_lossy().to_string();
797        git::git(repo, &["worktree", "add", &path, branch])
798            .await
799            .with_context(|| {
800                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
801            })?;
802
803        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
804            .await
805            .unwrap_or(0);
806        if commits == 0 {
807            git::worktree_remove(repo, &worktree).await.ok();
808            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
809        }
810        let files = git::changed_files(&worktree, &base_commit, "HEAD")
811            .await
812            .map(|f| f.len())
813            .unwrap_or(0);
814        if files == 0
815            && let (Ok(head_tree), Ok(base_tree)) = (
816                git::tree_of(&worktree, "HEAD").await,
817                git::tree_of(&worktree, &base_commit).await,
818            )
819            && head_tree == base_tree
820        {
821            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
822            git::worktree_remove(repo, &worktree).await.ok();
823            bail!(
824                "`{branch}` at {} has a tree identical to base {}; this usually means \
825                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
826                 against `{}/{branch}`) rather than an empty change",
827                short(&head),
828                short(&base_commit),
829                state.config.merge.remote
830            );
831        }
832        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
833            .await
834            .unwrap_or_default();
835
836        state.candidates.push(Candidate {
837            index: 0,
838            label: 'A',
839            // Not an agent id on purpose: nothing in the roster wrote this, and
840            // the stats tables must not credit anyone with a win for it.
841            agent: EXISTING_BRANCH.to_owned(),
842            branch: branch.to_owned(),
843            worktree,
844            summary: String::new(),
845            stat,
846            files,
847            commits,
848            empty: false,
849            failed: None,
850            verified_noop: None,
851            duration_ms: 0,
852            folded: false,
853        });
854        state.tally = Some(Tally {
855            first_choice: BTreeMap::from([('A', 0)]),
856            borda: BTreeMap::new(),
857            winner: 'A',
858            rankings: 0,
859            unanimous_initial: false,
860            deliberated: false,
861            changed_votes: 0,
862            unanimous_final: false,
863            tie_break: None,
864            // No panel sat, so no quorum applies. Zero judges is the correct
865            // number for work that never competed, and must not be reported as
866            // a collapsed panel.
867            judges: 0,
868            present: 0,
869            quorum: 0,
870            met_quorum: true,
871            uncontested: Some("review-only run: nothing competed".to_owned()),
872        });
873        state.status = RunStatus::Reviewing;
874        state.event(
875            "start",
876            format!(
877                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
878                state.id
879            ),
880        );
881        state.save()?;
882        Ok(Self {
883            state,
884            roles,
885            sem: Arc::new(Semaphore::new(max_parallel)),
886            pause: Pause::new(),
887            interrupt: Pause::new(),
888        })
889    }
890
891    /// Reopen an existing run.
892    pub fn resume(id: &str) -> Result<Self> {
893        let state = RunState::load(id)?;
894        if let Some(to) = &state.released_to {
895            bail!(
896                "run {} cannot be resumed: its worktree was released to run {}",
897                state.short(),
898                crate::run::short_of(to)
899            );
900        }
901        let roles = state.config.resolve_roles()?;
902        let max_parallel = state.config.graph.max_parallel.max(1);
903        Ok(Self {
904            state,
905            roles,
906            sem: Arc::new(Semaphore::new(max_parallel)),
907            pause: Pause::new(),
908            interrupt: Pause::new(),
909        })
910    }
911
912    /// Walk the graph to a terminal state, skipping nodes already recorded.
913    ///
914    /// Every way a run is driven - the queue loop, `magi run`, a resume from
915    /// the phone - ends here, so this is the one place a run that ended
916    /// Blocked / Stalled / Failed, or died with an error, is announced to the
917    /// notification centre. Best-effort: see [`crate::notices::raise`].
918    pub async fn execute(&mut self) -> Result<()> {
919        let result = self.execute_graph().await;
920        self.mark_driver_exited();
921        let ended = if result.is_err() {
922            Some(crate::notices::run_stopped(&self.state.id, &self.state))
923        } else {
924            crate::notices::run_ended(&self.state)
925        };
926        if let Some(notice) = ended {
927            crate::notices::raise(notice);
928        }
929        result
930    }
931
932    /// Record that this process no longer drives the run, so its pid (a
933    /// daemon's outlives the run) is not read as a live driver.
934    ///
935    /// Written onto the record as it is on disk, never this copy: another
936    /// process may have resumed the run (recording its own pid and clearing
937    /// the flag) or released its worktree since this copy was read, and
938    /// saving over that would mark a running driver dead. Only a record still
939    /// naming this process as the driver is touched.
940    fn mark_driver_exited(&mut self) {
941        self.state.driver_exited = true;
942        let pid = std::process::id();
943        let Ok(mut disk) = RunState::load(&self.state.id) else {
944            return;
945        };
946        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
947            return;
948        }
949        disk.driver_exited = true;
950        if let Err(e) = disk.save() {
951            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
952        }
953    }
954
955    async fn execute_graph(&mut self) -> Result<()> {
956        // Moving again, so it is no longer parked. Set before the walk rather
957        // than in `resume`, so every way of re-entering the graph clears it
958        // and a card cannot claim a run is waiting to be resumed while the
959        // agents are already working.
960        self.state.parked = false;
961        // Any seat this state still lists as answering belongs to whatever
962        // process last drove this run — this one included, if it crashed
963        // mid-wave. Cleared and flushed immediately, before anything else
964        // runs, so a resume can never show a seat as live when nothing is
965        // asking it anything yet; the node that actually dispatches the next
966        // wave repopulates it.
967        self.state.clear_active();
968        // Recorded in the same spot, and flushed together with the clear
969        // above: this is the pid a reader checks (`RunState::liveness`) when
970        // no daemon claim exists to answer "is a process still driving this
971        // run" — a plain `magi run` / `magi review` typed into a terminal
972        // claims nothing there. Always overwritten, never only-if-absent, so
973        // a resumed run's stale pid from a previous, possibly-dead process
974        // can never survive into this one's own report. Unlike
975        // `clear_active`, this changes on every single `execute()` call, so
976        // the save below is now unconditional rather than only-if-cleared.
977        //
978        // `driver_started_at` is recorded in the same breath, from this same
979        // pid, so `liveness` can tell a live pid that is genuinely still us
980        // apart from one the OS has since handed to an unrelated process —
981        // see that field's own doc for why the pid alone is not enough.
982        // A resume that raced a takeover: the record on disk says the worktree
983        // was handed to a later run after this copy was read. Saving over it
984        // would erase that and drive a run with nothing to run in.
985        if let Ok(disk) = RunState::load(&self.state.id)
986            && let Some(to) = &disk.released_to
987        {
988            bail!(
989                "run {} cannot continue: its worktree was released to run {}",
990                self.state.short(),
991                crate::run::short_of(to)
992            );
993        }
994        let pid = std::process::id();
995        self.state.driver_pid = Some(pid);
996        self.state.driver_started_at = crate::proc::process_started_at(pid);
997        self.state.driver_exited = false;
998        self.state.save()?;
999        // A run that already lost its quorum never resumes into the verdict
1000        // machinery: `deliberate` and `vote` would otherwise clobber the
1001        // stalled marker back to Voting and the run would keep going past a
1002        // verdict that is no longer trustworthy. Everything already recorded is
1003        // kept, so the run stays resumable (or foldable) for a human to pick up.
1004        //
1005        // On --resume the run gets one chance to repair itself: the seats a
1006        // rate limit took out are re-asked. If their quota has since reset and
1007        // the quorum is restored, the run picks up and finishes; otherwise it
1008        // stays stale and still-resumable for a later retry. If it does not
1009        // recover, the returned status stays `Stalled` and nothing was
1010        // clobbered (the recovery only mutates entries for the lost seats).
1011        if self.state.status == RunStatus::Stalled {
1012            if self.recover_stall().await? {
1013                self.finish_after_tally().await?;
1014            } else {
1015                // Still below quorum: persist the marker and stay resumable.
1016                self.state.save()?;
1017            }
1018            return Ok(());
1019        }
1020        // A run parked inside `land` - watching CI, mid fix-round, or
1021        // waiting on the owner's merge approval - resumes directly into it,
1022        // never back through `prep`. Everything before `merge` already
1023        // concluded; that is the only way `status` reaches `Landing` in the
1024        // first place. Re-walking `review_loop` first would also be actively
1025        // wrong: its own status recomputation (see its doc) treats any
1026        // clean round as reason to set `status` to `Gating`, which would
1027        // clobber this marker before `merge` ever ran, and this run would
1028        // never find its way back into `land` at all.
1029        if self.state.status == RunStatus::Landing {
1030            self.run_land().await?;
1031            // `run_land` may have settled the run right here - CI came back
1032            // green and the PR merged, say - without ever passing back
1033            // through `merge`'s own trailing call. Whatever it left `status`
1034            // as is what this has to read.
1035            self.settle_questions();
1036            return Ok(());
1037        }
1038        self.prep().await?;
1039        if self.park_here()? {
1040            return Ok(());
1041        }
1042        self.advise().await?;
1043        if self.park_here()? {
1044            return Ok(());
1045        }
1046        self.implement().await?;
1047        if self.park_here()? {
1048            return Ok(());
1049        }
1050        // `after_implement` already saved the state and settled any open
1051        // questions when it set this; nothing later in the graph has
1052        // anything to judge.
1053        if self.state.status == RunStatus::VerifiedNoop {
1054            return Ok(());
1055        }
1056        self.judge().await?;
1057        if self.park_here()? {
1058            return Ok(());
1059        }
1060        self.deliberate().await?;
1061        if self.park_here()? {
1062            return Ok(());
1063        }
1064        self.vote().await?;
1065        if self.park_here()? {
1066            return Ok(());
1067        }
1068        self.tally()?;
1069        // A verdict that lost its quorum is not trustworthy: do not review,
1070        // gate, or merge on it. Everything already done is kept, so the run
1071        // stays resumable (or foldable); the human can replace the agent that
1072        // ran out of quota and pick it up.
1073        if self.state.status == RunStatus::Stalled {
1074            // Persist the stalled marker now — the normal end-of-execute save
1075            // below is below this early return, and without it a resumed run
1076            // would reload a pre-tally status and keep going.
1077            self.state.save()?;
1078            return Ok(());
1079        }
1080        self.finish_after_tally().await?;
1081        Ok(())
1082    }
1083
1084    /// Park here if asked to, recording it in the run's own timeline.
1085    ///
1086    /// Returns whether the caller should stop walking the graph. The state is
1087    /// saved either way by the node that just finished; this adds the event so
1088    /// the operator's card says why a run that is neither finished nor moving
1089    /// is sitting where it is.
1090    fn park_here(&mut self) -> Result<bool> {
1091        // Either handle asking is enough - see `Pause`'s own doc for why
1092        // they are never the same one. `interrupt` is checked second so a
1093        // reason it carries is preferred in the message below over a plain
1094        // shutdown park racing it at the same boundary.
1095        if !self.pause.parked() && !self.interrupt.parked() {
1096            return Ok(false);
1097        }
1098        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1099            Some(reason) => format!(
1100                "parked after `{}` ({reason}) — resume to carry on from here",
1101                self.state.status.as_str()
1102            ),
1103            None => format!(
1104                "parked after `{}` — resume to carry on from here",
1105                self.state.status.as_str()
1106            ),
1107        };
1108        self.state.event("park", why);
1109        self.state.parked = true;
1110        self.state.save()?;
1111        Ok(true)
1112    }
1113
1114    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1115    pub fn on_pause(&mut self, pause: Pause) {
1116        self.pause = pause;
1117    }
1118
1119    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1120    /// scheduler asking this one run - and no other - to park so a task
1121    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1122    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1123    /// handle.
1124    pub fn watch_interrupt(&mut self, pause: Pause) {
1125        self.interrupt = pause;
1126    }
1127
1128    /// Abandon this run's own open questions, once `status` has actually
1129    /// settled rather than merely paused.
1130    ///
1131    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1132    /// either back up with the candidates, the review round and the seat
1133    /// sessions already on disk, so a question an implementer asked mid-round
1134    /// may still get a real answer read by a real resume. Only the statuses
1135    /// `resumable` excludes are actually final: the run merged, it reached
1136    /// `Ready` with nothing left to do, it failed outright with no
1137    /// established point to continue from, or every candidate agreed, with
1138    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1139    /// every one of those the seat that asked is gone for good, exactly like
1140    /// the run being deleted under `magi run rm` - so the same cleanup
1141    /// applies, worded for what actually happened instead of "the run was
1142    /// deleted".
1143    ///
1144    /// Best-effort and silent on success: called from every place `status`
1145    /// can land on one of those three, including ones a resumed run revisits,
1146    /// so it must cost nothing when there was nothing open to begin with.
1147    fn settle_questions(&mut self) {
1148        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1149            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1150        }
1151    }
1152
1153    /// The tail of the graph after a trustworthy tally: fold losers, review,
1154    /// gate, merge, and persist.
1155    async fn finish_after_tally(&mut self) -> Result<()> {
1156        self.fold_losers().await?;
1157        // Before review starts, and again right before the gate: a run's
1158        // review rounds can themselves take long enough for the base to move
1159        // a second time, and the gate is the one node whose "green" gets
1160        // acted on.
1161        self.sync_to_base().await?;
1162        if self.state.status == RunStatus::AlreadyInBase {
1163            return Ok(());
1164        }
1165        self.review_loop().await?;
1166        self.sync_to_base().await?;
1167        if self.state.status == RunStatus::AlreadyInBase {
1168            return Ok(());
1169        }
1170        self.gate().await?;
1171        self.merge().await?;
1172        self.state.save()?;
1173        Ok(())
1174    }
1175
1176    // ---------------------------------------------------------------- prep
1177
1178    async fn prep(&mut self) -> Result<()> {
1179        if !self.state.candidates.is_empty() {
1180            return Ok(());
1181        }
1182        self.state.status = RunStatus::Prep;
1183        let repo = self.state.repo.clone();
1184        let base = self.state.base_commit.clone();
1185        let plan = refs::plan(&repo, &self.state.seeds).await?;
1186        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1187        let root = self.state.worktree_root();
1188        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1189
1190        // The hook is the write-time half of the blindness contract; the
1191        // presentation filter in `blind` is the half that cannot be bypassed.
1192        let hooks_dir = self.state.dir().join("hooks");
1193        if self.state.config.blind.commit_msg_hook {
1194            std::fs::create_dir_all(&hooks_dir)
1195                .with_context(|| format!("create {}", hooks_dir.display()))?;
1196            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1197            let path = hooks_dir.join("commit-msg");
1198            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1199            make_executable(&path)?;
1200            // Ref-counted rather than a plain idempotent set: with more than
1201            // one run able to be in flight in the same repository at once
1202            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1203            // true?" check cannot tell "another run of mine still needs
1204            // this" from "nobody does", and the run that happens to finish
1205            // first would disable the hook out from under a sibling still
1206            // relying on it.
1207            git::acquire_worktree_config(&repo).await?;
1208            self.state.enabled_worktree_config = true;
1209        }
1210
1211        for (index, (spec, label)) in self
1212            .roles
1213            .implementers
1214            .clone()
1215            .into_iter()
1216            .zip(labels)
1217            .enumerate()
1218        {
1219            let branch = self.state.branch_for(label);
1220            let worktree = root.join(format!("cand-{label}"));
1221            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1222            if self.state.config.blind.commit_msg_hook {
1223                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1224            }
1225            git::local_exclude(&worktree, "/.magi/").await?;
1226            for pick in &plan.picks {
1227                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1228                    self.state.status = RunStatus::Blocked;
1229                    self.state
1230                        .event("prep", format!("cannot apply referenced commit: {e}"));
1231                    self.state.save()?;
1232                    return Err(e);
1233                }
1234            }
1235            self.state.candidates.push(Candidate {
1236                index,
1237                label,
1238                agent: spec.id.clone(),
1239                branch,
1240                worktree,
1241                summary: String::new(),
1242                stat: String::new(),
1243                files: 0,
1244                commits: 0,
1245                empty: false,
1246                failed: None,
1247                verified_noop: None,
1248                duration_ms: 0,
1249                folded: false,
1250            });
1251        }
1252
1253        for j in 1..=self.roles.judges.len() {
1254            let wt = root.join(format!("judge-{j}"));
1255            if !wt.exists() {
1256                git::worktree_add_detached(&repo, &wt, &base).await?;
1257            }
1258        }
1259
1260        // Disposable, detached checkouts for the design-deliberation stage's
1261        // advisor seats — the same shape as the judges' above, at the same
1262        // base commit, since advisors also only ever read. Sized off the
1263        // configured count directly rather than a resolved roster: unlike
1264        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1265        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1266        // `prep` has no `ResolvedRoles` field to read a count from here.
1267        if self.state.config.graph.advise {
1268            for k in 1..=self.state.config.graph.advisors {
1269                let wt = root.join(format!("advisor-{k}"));
1270                if !wt.exists() {
1271                    git::worktree_add_detached(&repo, &wt, &base).await?;
1272                }
1273            }
1274        }
1275
1276        // A judge cannot tell it is looking at its own patch — the seats keep
1277        // separate conversations — but a panel that shares agents with the
1278        // field is less independent than it looks, and that is worth saying out
1279        // loud once per run rather than leaving it in the config.
1280        let authors: Vec<&str> = self
1281            .roles
1282            .implementers
1283            .iter()
1284            .map(|a| a.id.as_str())
1285            .collect();
1286        let overlap: Vec<String> = self
1287            .roles
1288            .judges
1289            .iter()
1290            .enumerate()
1291            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1292            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1293            .collect();
1294        if !overlap.is_empty() {
1295            let note = format!(
1296                "{} also authored a candidate; blind, but the panel is less \
1297                 independent than {} distinct agents would be",
1298                overlap.join(", "),
1299                self.roles.judges.len()
1300            );
1301            self.state.event("prep", note);
1302        }
1303
1304        self.state.event(
1305            "prep",
1306            format!(
1307                "{} candidates, {} judges, base {} ({})",
1308                self.state.candidates.len(),
1309                self.roles.judges.len(),
1310                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1311                self.state.base_branch
1312            ),
1313        );
1314        self.state.status = RunStatus::Implementing;
1315        self.state.save()?;
1316        Ok(())
1317    }
1318
1319    // -------------------------------------------------------------- advise
1320
1321    /// The design-deliberation stage: independent, read-only advisor seats
1322    /// each sketch a design before any implementer touches the repository,
1323    /// and (when at least one produced a usable proposal) a synthesis seat
1324    /// blends them into a brief `implement` carries in every candidate's
1325    /// prompt.
1326    ///
1327    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1328    /// advisors` is the proposal count. Everything here is best-effort and
1329    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1330    /// that cannot reach quota, or a synthesis seat that produced nothing
1331    /// usable all leave `implement` exactly as it was before this stage
1332    /// existed — the task instruction alone — rather than failing the whole
1333    /// competition over an enrichment stage. Every outcome is still recorded
1334    /// as an event, so a run that got nothing from this stage says why.
1335    ///
1336    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1337    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1338    /// resumed run whose stage failed would re-run it, and re-spend the
1339    /// agent calls, on every reentry before `implement`.
1340    ///
1341    /// Also skipped once any candidate shows implementation progress — the
1342    /// exact predicate `implement` itself uses to decide a candidate is no
1343    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1344    /// is not enough: a run created by an older binary that predates this
1345    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1346    /// an already-`Implementing`-or-later run under this build would
1347    /// otherwise walk straight back through `prep` (a no-op once candidates
1348    /// exist) into this node and spawn every advisor seat against worktrees
1349    /// `prep` never recreated — after implementation has already started,
1350    /// which is exactly the invariant this stage exists to guarantee.
1351    async fn advise(&mut self) -> Result<()> {
1352        let implement_untouched = self
1353            .state
1354            .candidates
1355            .iter()
1356            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1357        if !self.state.config.graph.advise || self.state.advise_attempted {
1358            return Ok(());
1359        }
1360        if !implement_untouched {
1361            self.state.event(
1362                "advise",
1363                "skipping the design-deliberation stage: at least one \
1364                 candidate already shows implementation progress, so this \
1365                 run is past the point the stage exists to run before"
1366                    .to_owned(),
1367            );
1368            self.state.advise_attempted = true;
1369            self.state.save()?;
1370            return Ok(());
1371        }
1372        let run_id = self.state.id.clone();
1373        let prompts = self.state.config.prompts.clone();
1374        let instruction = self.state.instruction.clone();
1375        let language = self.state.config.graph.language.clone();
1376        let root = self.state.worktree_root();
1377        let n = self.state.config.graph.advisors;
1378        let where_recorded = self.state.dir().join("run.json");
1379
1380        let seats = match self.state.config.advisors() {
1381            Ok(seats) if !seats.is_empty() => seats,
1382            Ok(_) => {
1383                self.state.event(
1384                    "advise",
1385                    format!(
1386                        "[graph] advisors is 0; skipping the design-deliberation \
1387                         stage and continuing without a synthesis brief (see {})",
1388                        where_recorded.display()
1389                    ),
1390                );
1391                self.state.advise_attempted = true;
1392                self.state.save()?;
1393                return Ok(());
1394            }
1395            Err(e) => {
1396                self.state.event(
1397                    "advise",
1398                    format!(
1399                        "could not resolve advisor seats ({e:#}); continuing \
1400                         without a design-deliberation brief (see {})",
1401                        where_recorded.display()
1402                    ),
1403                );
1404                self.state.advise_attempted = true;
1405                self.state.save()?;
1406                return Ok(());
1407            }
1408        };
1409
1410        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1411        let artifacts = agent::artifacts_dir(&self.state.dir());
1412        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1413
1414        let mut jobs = Vec::new();
1415        for (i, spec) in seats.iter().cloned().enumerate() {
1416            let seat_key = format!("advisor-{}", i + 1);
1417            let seat = self.seat(&seat_key, &spec.id);
1418            jobs.push(SeatJob {
1419                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1420                spec,
1421                seat,
1422                cwd: worktrees[i % worktrees.len()].clone(),
1423                timeout,
1424                allow_write: false,
1425                sessions: false,
1426                artifacts: artifacts.clone(),
1427                stem: seat_key,
1428                handover: None,
1429            });
1430        }
1431
1432        self.state.event(
1433            "advise",
1434            format!(
1435                "{} advisor seat(s) sketching a design in parallel",
1436                jobs.len()
1437            ),
1438        );
1439        let mut quota_losses = Vec::new();
1440        let cache = self.state.config.cache_dir();
1441        let ctx = WaveCtx {
1442            carry_seats: false,
1443            run: &run_id,
1444            node: "advise",
1445            prompts: &prompts,
1446            cache: cache.as_deref(),
1447            round: None,
1448        };
1449        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1450        let results = ask_json_wave::<Proposal>(
1451            jobs,
1452            Arc::clone(&self.sem),
1453            self.state.config.graph.retries,
1454            &advisor_roster,
1455            &ctx,
1456            &mut quota_losses,
1457            &mut self.state,
1458            &|p: &Proposal| p.validate(),
1459        )
1460        .await;
1461        self.state.quota.extend(quota_losses);
1462
1463        let mut records = Vec::with_capacity(results.len());
1464        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1465            let agent_id = seat.agent.clone();
1466            self.state.seats.insert(seat.key.clone(), seat);
1467            match res {
1468                Ok((proposal, out)) => {
1469                    self.state
1470                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1471                    records.push(advise::AdvisorRecord::proposed(
1472                        i + 1,
1473                        agent_id,
1474                        proposal,
1475                        out.duration_ms,
1476                    ));
1477                }
1478                Err(e) => {
1479                    self.state.event(
1480                        "advise",
1481                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1482                    );
1483                    records.push(advise::AdvisorRecord::failed(
1484                        i + 1,
1485                        agent_id,
1486                        e.to_string(),
1487                    ));
1488                }
1489            }
1490        }
1491
1492        let mut advice = advise::Advice {
1493            records,
1494            synthesis: None,
1495        };
1496        if advice.proposals().is_empty() {
1497            self.state.event(
1498                "advise",
1499                "no advisor produced a usable proposal; continuing without a \
1500                 synthesis brief"
1501                    .to_owned(),
1502            );
1503        } else {
1504            match self
1505                .synthesize_brief(
1506                    &advice,
1507                    &instruction,
1508                    &language,
1509                    &worktrees[0],
1510                    &artifacts,
1511                    &run_id,
1512                    &prompts,
1513                    cache.as_deref(),
1514                )
1515                .await
1516            {
1517                Ok(Some(text)) => {
1518                    self.state.event(
1519                        "advise",
1520                        "synthesized a design brief for the implementer".to_owned(),
1521                    );
1522                    advice.synthesis = Some(text);
1523                }
1524                Ok(None) => {
1525                    self.state.event(
1526                        "advise",
1527                        "the synthesis seat produced nothing usable; continuing \
1528                         without a design brief"
1529                            .to_owned(),
1530                    );
1531                }
1532                Err(e) => {
1533                    self.state.event(
1534                        "advise",
1535                        format!("could not synthesize a design brief: {e:#}"),
1536                    );
1537                }
1538            }
1539        }
1540        advise::apply_reflection(&mut advice);
1541
1542        self.state.advice = Some(advice);
1543        self.state.advise_attempted = true;
1544        self.state.save()?;
1545        Ok(())
1546    }
1547
1548    /// The synthesis seat: reads every advisor's proposal and blends them
1549    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1550    /// out of [`Runner::advise`] only for readability — it is not called
1551    /// anywhere else.
1552    ///
1553    /// Picked the same way [`crate::talk`]'s standing conversation and
1554    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1555    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1556    /// order (a claude seat, else the first runnable agent in roster order)
1557    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1558    /// in [`crate::config`] for why a dedicated field exists here at all.
1559    #[allow(clippy::too_many_arguments)]
1560    async fn synthesize_brief(
1561        &mut self,
1562        advice: &advise::Advice,
1563        instruction: &str,
1564        language: &str,
1565        cwd: &Path,
1566        artifacts: &Path,
1567        run_id: &str,
1568        prompts: &Prompts,
1569        cache: Option<&Path>,
1570    ) -> Result<Option<String>> {
1571        let chain = agent::pick_chain(
1572            &self.state.config.agents,
1573            self.state.config.roles.synthesizer.as_ref(),
1574            &agent::installed,
1575            "synthesizer",
1576        )?;
1577        let proposals = advice.proposals();
1578        let mut prompt = prompt::with_overlay(
1579            prompt::synthesize_brief(instruction, &proposals, language),
1580            prompts.overlay("advise"),
1581        );
1582        if cache.is_some() {
1583            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1584            // below — see `prompt::build_cache_note`'s doc for why telling a
1585            // read-only seat to build through the shared cache is exactly how
1586            // a sandbox's write refusal gets misread as a defect.
1587            prompt.push('\n');
1588            prompt.push_str(&prompt::build_cache_note("advise", false));
1589        }
1590        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1591        // Each id is tried once, in order; a quota hit, error or unusable
1592        // answer moves to the next. The seat is single-turn (`sessions:
1593        // false`) and the prompt is the whole context, so a fallback agent
1594        // needs nothing carried over.
1595        let mut last = None;
1596        for (n, spec) in chain.iter().enumerate() {
1597            if n > 0 {
1598                self.state
1599                    .event("advise", format!("synthesis falling back to {}", spec.id));
1600            }
1601            let mut seat = self.seat("advise-synthesis", &spec.id);
1602            let outcome = agent::invoke(
1603                spec,
1604                &mut seat,
1605                &Invocation {
1606                    cwd,
1607                    prompt: &prompt,
1608                    timeout,
1609                    allow_write: false,
1610                    sessions: false,
1611                    artifacts,
1612                    stem: &if n == 0 {
1613                        "advise-synthesis".to_owned()
1614                    } else {
1615                        format!("advise-synthesis-{}", spec.id)
1616                    },
1617                    run: run_id,
1618                    node: "advise",
1619                    cache_dir: None,
1620                    attachments: &[],
1621                    writable: &[],
1622                },
1623            )
1624            .await;
1625            if outcome.is_ok() {
1626                self.state.seats.insert(seat.key.clone(), seat);
1627            }
1628            let advance = agent::chain_advances(&outcome);
1629            last = Some(outcome);
1630            if !advance {
1631                break;
1632            }
1633        }
1634        // Exhausted: the last attempt's result is what a single failed seat
1635        // would have produced.
1636        let out = last.expect("a chain holds at least one agent")?;
1637        if !out.usable() {
1638            return Ok(None);
1639        }
1640        let text =
1641            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1642        Ok((!text.trim().is_empty()).then_some(text))
1643    }
1644
1645    // ----------------------------------------------------------- implement
1646
1647    async fn implement(&mut self) -> Result<()> {
1648        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1649        // agent files with `magi task add` name the run that paid for it. The
1650        // prompt overlay is cloned alongside it because the waves borrow it
1651        // while `self` is mutably borrowed by the node's own bookkeeping.
1652        let run_id = self.state.id.clone();
1653        let prompts = self.state.config.prompts.clone();
1654        let todo: Vec<usize> = self
1655            .state
1656            .candidates
1657            .iter()
1658            .enumerate()
1659            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1660            .map(|(i, _)| i)
1661            .collect();
1662        if todo.is_empty() {
1663            return self.after_implement();
1664        }
1665        self.state.status = RunStatus::Implementing;
1666
1667        let language = self.state.config.graph.language.clone();
1668        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1669        let sessions = self.state.config.graph.sessions;
1670        let artifacts = agent::artifacts_dir(&self.state.dir());
1671        // The design-deliberation stage's blended brief, when `advise` found
1672        // one — carried into every implementer's prompt the same way
1673        // regardless of which candidate it is.
1674        let brief = self
1675            .state
1676            .advice
1677            .as_ref()
1678            .and_then(|a| a.synthesis.as_deref())
1679            .map(str::to_owned);
1680        let attachments = self.state.attachments.clone();
1681
1682        let mut jobs = Vec::new();
1683        for &i in &todo {
1684            let (index, label, worktree) = {
1685                let c = &self.state.candidates[i];
1686                (c.index, c.label, c.worktree.clone())
1687            };
1688            let spec = self.roles.implementers[index].clone();
1689            let seat_key = format!("impl-{label}");
1690            let seat = self.seat(&seat_key, &spec.id);
1691            let instruction = seeded_instruction(&self.state);
1692            jobs.push(SeatJob {
1693                spec,
1694                seat,
1695                prompt: prompt::implement(
1696                    &instruction,
1697                    &worktree.to_string_lossy(),
1698                    &language,
1699                    brief.as_deref(),
1700                    &attachments,
1701                ),
1702                cwd: worktree,
1703                timeout,
1704                allow_write: true,
1705                sessions,
1706                artifacts: artifacts.clone(),
1707                stem: format!("impl-{label}"),
1708                handover: None,
1709            });
1710        }
1711
1712        self.state.event(
1713            "implement",
1714            format!("{} candidates in parallel", jobs.len()),
1715        );
1716        // Kept so a seat whose CLI hung up can be asked again from the same
1717        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1718        // can update a seat's own entry once a fallback agent takes it over —
1719        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1720        // whichever agent actually answered, not the one that quota'd out.
1721        let mut sent = jobs.clone();
1722        let cache = self.state.config.cache_dir();
1723        let ctx = WaveCtx {
1724            carry_seats: false,
1725            run: &run_id,
1726            node: "implement",
1727            prompts: &prompts,
1728            cache: cache.as_deref(),
1729            round: None,
1730        };
1731        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1732        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1733            .await;
1734        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1735            .await;
1736        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1737            .await;
1738
1739        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1740            let seat_key = seat.key.clone();
1741            // A quota fallback (`resume_seat_handovers`) may have handed this
1742            // seat to a different agent than the one `prep` recorded on the
1743            // candidate; the stats tables and any later fixer-defaults-to-
1744            // winner's-author lookup must credit whoever actually answered —
1745            // unless every fallback also quota'd out, in which case nobody
1746            // actually answered and crediting the last agent tried would
1747            // erase every earlier agent's own quota loss from the stats
1748            // tables instead of just this one seat's.
1749            let agent = seat.agent.clone();
1750            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1751            self.state.seats.insert(seat.key.clone(), seat);
1752            let label = self.state.candidates[i].label;
1753            let worktree = self.state.candidates[i].worktree.clone();
1754            let base = self.state.base_commit.clone();
1755
1756            let (summary, duration, failed, verified_claim) = match out {
1757                AgentOutcome::Ok(o) => {
1758                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1759                    let failed = (!o.usable()).then(|| {
1760                        if o.timed_out {
1761                            "agent timed out".to_owned()
1762                        } else {
1763                            format!("agent exited with {:?}", o.exit_code)
1764                        }
1765                    });
1766                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1767                    (text, o.duration_ms, failed, verified_claim)
1768                }
1769                // Left un-resumed by `resume_undelivered` (a dirty tree
1770                // already rescues the work, or there was no session left to
1771                // resume into) — reported like the ordinary failure it is,
1772                // never as if `o.text` (the CLI's raw error JSON) were an
1773                // answer.
1774                AgentOutcome::Dropped(o) => {
1775                    let why = o
1776                        .dropped
1777                        .as_ref()
1778                        .map(|d| d.why.as_str())
1779                        .unwrap_or("the CLI ended the stream without delivering its answer");
1780                    (
1781                        String::new(),
1782                        o.duration_ms,
1783                        Some(format!("the CLI dropped the stream ({why})")),
1784                        None,
1785                    )
1786                }
1787                AgentOutcome::Quota(o) => {
1788                    self.state.quota.push(QuotaLoss {
1789                        seat: seat_key,
1790                        node: "implement".to_owned(),
1791                        at: Timestamp::now(),
1792                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1793                    });
1794                    (
1795                        String::new(),
1796                        o.duration_ms,
1797                        Some("rate limited (quota); produced no change".to_owned()),
1798                        None,
1799                    )
1800                }
1801                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1802            };
1803
1804            // Rescue anything the agent edited but never committed: an
1805            // uncommitted candidate would silently be an empty one.
1806            let rescued = match git::rescue_commit(
1807                &worktree,
1808                &format!("magi: candidate {label} (uncommitted work)"),
1809            )
1810            .await
1811            {
1812                Ok(r) => {
1813                    self.state.note_withheld("implement", &r.withheld);
1814                    r.committed
1815                }
1816                Err(_) => false,
1817            };
1818            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1819                .await
1820                .unwrap_or(0);
1821            let patch = git::diff(&worktree, &base, "HEAD")
1822                .await
1823                .unwrap_or_default();
1824            let stat = git::diff_stat(&worktree, &base, "HEAD")
1825                .await
1826                .unwrap_or_default();
1827            let files = git::changed_files(&worktree, &base, "HEAD")
1828                .await
1829                .map(|f| f.len())
1830                .unwrap_or(0);
1831            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1832
1833            let c = &mut self.state.candidates[i];
1834            if !exhausted_the_fallback_chain {
1835                c.agent = agent;
1836            }
1837            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1838            c.stat = stat;
1839            c.files = files;
1840            c.commits = commits;
1841            c.duration_ms = duration;
1842            c.empty = commits == 0 || patch.trim().is_empty();
1843            // An agent that failed but still produced a committed change stays
1844            // in the running: the patch is what gets judged, not the exit code.
1845            c.failed = match failed {
1846                Some(_) if c.empty => failed,
1847                _ => None,
1848            };
1849            // Only an empty candidate can be a verified no-op: a claim next
1850            // to a real patch is not what the marker is for, and `c.failed`
1851            // being `Some` here already implies `verified_claim` was never
1852            // set (see the guard above the match that produced it).
1853            c.verified_noop = if c.empty { verified_claim } else { None };
1854            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1855                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1856                (None, true, Some(_), _) => {
1857                    format!("candidate {label}: no change produced (agent-verified no-op)")
1858                }
1859                (None, true, None, _) => format!("candidate {label}: no change produced"),
1860                (None, false, _, true) => {
1861                    format!(
1862                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1863                    )
1864                }
1865                (None, false, _, false) => {
1866                    format!("candidate {label}: {files} files, {commits} commits")
1867                }
1868            };
1869            self.state.event("implement", note);
1870            self.state.save()?;
1871        }
1872
1873        self.after_implement()
1874    }
1875
1876    /// Ask again, once, for work a CLI did and then failed to hand over.
1877    ///
1878    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1879    /// status with an empty response and a usage report showing output tokens,
1880    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1881    /// seven minutes and 14,267 output tokens that arrived as an empty
1882    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1883    ///
1884    /// Two conditions, and both matter:
1885    ///
1886    /// - **Only when the tree is untouched.** Often the agent has already
1887    ///   written its files and only the closing message was lost; the rescue
1888    ///   commit below picks that up and there is nothing to ask for. Re-asking
1889    ///   then would pay for a second implementation of work already on disk.
1890    /// - **Once.** A CLI that drops one stream can drop the next, and this
1891    ///   node is the most expensive in the graph.
1892    ///
1893    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1894    /// dropped reply still carried its `conversation_id`, so the seat is asked
1895    /// to finish what it was doing rather than sent the whole task again. It
1896    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
1897    /// node's - for the same reason a re-ranked judge does: restating finished
1898    /// work is not the work.
1899    ///
1900    /// Unlike a quota this is worth retrying at all: a rate limit fails the
1901    /// same way until it resets, while an abandoned conversation is still
1902    /// there to be picked up.
1903    async fn resume_undelivered(
1904        &mut self,
1905        results: &mut [(usize, SeatState, AgentOutcome)],
1906        sent: &[SeatJob],
1907        prompts: &Prompts,
1908        run_id: &str,
1909    ) {
1910        for (wi, seat, out) in results.iter_mut() {
1911            let Some(dropped) = (match &*out {
1912                AgentOutcome::Dropped(o) => o.dropped.clone(),
1913                _ => None,
1914            }) else {
1915                continue;
1916            };
1917            let Some(job) = sent.get(*wi) else { continue };
1918            // Already on disk? Then only the closing message was lost.
1919            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1920                self.state.event(
1921                    "implement",
1922                    format!(
1923                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1924                         work is in the tree",
1925                        seat.key, dropped.output_tokens, dropped.why
1926                    ),
1927                );
1928                continue;
1929            }
1930            // The re-ask only makes sense as a resume: `resume_after_drop`
1931            // says nothing about the task, trusting the seat to still hold it.
1932            // Without a session to resume — sessions disabled, or this CLI's
1933            // drop shape happened not to carry a session id — that prompt
1934            // would open a brand-new conversation with no context at all,
1935            // which is worse than leaving this as the ordinary failure it
1936            // already is.
1937            if !has_context(&job.spec, seat, job.sessions) {
1938                self.state.event(
1939                    "implement",
1940                    format!(
1941                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1942                         is no session left to resume",
1943                        seat.key, dropped.output_tokens, dropped.why
1944                    ),
1945                );
1946                continue;
1947            }
1948            self.state.event(
1949                "implement",
1950                format!(
1951                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
1952                     conversation",
1953                    seat.key, dropped.output_tokens, dropped.why
1954                ),
1955            );
1956            let mut retry = job.clone();
1957            retry.seat = seat.clone();
1958            retry.prompt = prompt::resume_after_drop(&dropped.why);
1959            retry.timeout = retry_budget(job.timeout, true);
1960            retry.stem = format!("{}-resume", job.stem);
1961            let cache = self.state.config.cache_dir();
1962            let ctx = WaveCtx {
1963                carry_seats: false,
1964                run: run_id,
1965                node: "implement",
1966                prompts,
1967                cache: cache.as_deref(),
1968                round: None,
1969            };
1970            let (resumed_seat, resumed) =
1971                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
1972            *seat = resumed_seat;
1973            *out = resumed;
1974        }
1975    }
1976
1977    /// Fall an implement seat through to the next untried agent in the
1978    /// implementer roster when it lost to quota — or, since the handover was
1979    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
1980    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
1981    /// quota path itself being unchanged — instead of leaving the
1982    /// seat's loss final the moment one agent's account runs dry.
1983    ///
1984    /// Solo runs (`graph.candidates = 1`, `daemon::apply_solo`'s forced shape)
1985    /// are the motivating case: `Config::resolve_roles`'s `implementers`
1986    /// truncates to the single slot rotation picked, so a solo task whose one
1987    /// implementer hits quota mid-run used to have nothing else to try. This
1988    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
1989    /// unrotated roster — which is the only place the *other* candidates in
1990    /// the machine's roster still exist once `implementers` has been cut down
1991    /// to size.
1992    ///
1993    /// Walks forward from just past the seat's own original position in the
1994    /// roster, never wrapping back to the front: a later candidate slot (say
1995    /// `beta`, the roster's second entry) must fall through to the *next*
1996    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
1997    /// almost certainly a different candidate's own agent already — and once
1998    /// the roster's tail is exhausted there is nothing left to fall through
1999    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2000    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2001    /// let this retry that id forever. The loop keeps falling through until
2002    /// an attempt lands something other than `Quota` or the roster's tail
2003    /// runs out of untried ids, at which point the seat is left exactly as
2004    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2005    /// `QuotaLoss` recorded, the candidate failed/empty.
2006    ///
2007    /// `sent` is taken mutably and updated with the fallback agent's spec:
2008    /// `resume_unconfirmed_commands`, which runs after this and also reads
2009    /// `sent`, must see whichever agent actually ended up answering the seat
2010    /// — reading the stale, original spec there would check session
2011    /// eligibility against the wrong CLI and could hand a fallback agent's
2012    /// session id to the agent that just lost the seat to quota.
2013    ///
2014    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2015    /// — `self.seat` only reuses state when the agent id is unchanged, so
2016    /// handing it a different id already gets this for free. Reusing the old
2017    /// seat would resume a different CLI's session as if it were a
2018    /// continuation of this one.
2019    ///
2020    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2021    /// a quota loss cuts an agent off mid-turn, so anything already in the
2022    /// tree is unfinished work, not a completed candidate a re-ask would pay
2023    /// for twice. A dirty tree is rescued into a commit first (the same
2024    /// neutral-identity rescue `implement`'s own outcome loop gives every
2025    /// candidate) so the next agent starts clean.
2026    ///
2027    /// The new agent gets the implementer's full prompt and full
2028    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2029    /// it has no session and no context, and is implementing the task from
2030    /// nothing, unlike a resumed drop which is only restating work already
2031    /// done.
2032    ///
2033    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2034    /// `implement` event, never into `self.state.quota` — that is what
2035    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2036    /// go unspent, and a seat that ultimately recovered on its second or
2037    /// third agent is not the stalled panel that check exists to catch. Only
2038    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2039    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2040    /// outcome loop already has — this helper never pushes to it itself.
2041    async fn resume_seat_handovers(
2042        &mut self,
2043        results: &mut [(usize, SeatState, AgentOutcome)],
2044        sent: &mut [SeatJob],
2045        prompts: &Prompts,
2046        run_id: &str,
2047    ) {
2048        let instruction = seeded_instruction(&self.state);
2049        let language = self.state.config.graph.language.clone();
2050        let brief = self
2051            .state
2052            .advice
2053            .as_ref()
2054            .and_then(|a| a.synthesis.as_deref())
2055            .map(str::to_owned);
2056        let attachments = self.state.attachments.clone();
2057        for (wi, seat, out) in results.iter_mut() {
2058            let Some(job) = sent.get_mut(*wi) else {
2059                continue;
2060            };
2061            // Where the seat's own original agent sits in the roster — the
2062            // fallback walk starts just past here, never at the front, so a
2063            // later candidate slot's quota loss does not fall back onto an
2064            // earlier slot's own agent.
2065            let start = self
2066                .roles
2067                .implementer_roster
2068                .iter()
2069                .position(|s| s.id == job.spec.id)
2070                .unwrap_or(0);
2071            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2072            let mut fallback_attempt = 0usize;
2073            let mut prev: Option<FailClass> = None;
2074            while let Some(cur) = FailClass::of(&*out) {
2075                if !should_hand_over(prev.as_ref(), &cur) {
2076                    break;
2077                }
2078                let Some(next) =
2079                    next_untried_in_roster(&self.roles.implementer_roster, start, &tried).cloned()
2080                else {
2081                    break;
2082                };
2083                tried.insert(next.id.clone());
2084                fallback_attempt += 1;
2085
2086                if let Ok(r) = git::rescue_commit(
2087                    &job.cwd,
2088                    &format!(
2089                        "magi: candidate {} (uncommitted work before {} fallback)",
2090                        seat.key,
2091                        if cur == FailClass::Quota {
2092                            "quota"
2093                        } else {
2094                            "handover"
2095                        }
2096                    ),
2097                )
2098                .await
2099                {
2100                    self.state.note_withheld("implement", &r.withheld);
2101                }
2102
2103                record_handover(
2104                    &mut self.state,
2105                    "implement",
2106                    &seat.key,
2107                    &seat.agent,
2108                    &next.id,
2109                    &cur,
2110                    &fail_reason(&*out),
2111                );
2112                prev = Some(cur.clone());
2113
2114                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2115                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2116                // Kept in sync on `sent` itself, not just the local retry: a
2117                // later helper (`resume_unconfirmed_commands`) reads `sent`
2118                // after this one returns and must see whichever agent is now
2119                // occupying the seat, not the one that just quota'd out —
2120                // otherwise it would judge session/continuation eligibility
2121                // by the wrong CLI and could resend a fallback's session id
2122                // to the agent that lost it the seat in the first place.
2123                job.spec = next.clone();
2124                let mut retry = job.clone();
2125                retry.seat = new_seat;
2126                retry.prompt = prompt::implement(
2127                    &instruction,
2128                    &job.cwd.to_string_lossy(),
2129                    &language,
2130                    brief.as_deref(),
2131                    &attachments,
2132                );
2133                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2134                let cache = self.state.config.cache_dir();
2135                let ctx = WaveCtx {
2136                    carry_seats: false,
2137                    run: run_id,
2138                    node: "implement",
2139                    prompts,
2140                    cache: cache.as_deref(),
2141                    round: None,
2142                };
2143                let (fallback_seat, fallback_out) = run_one(
2144                    retry,
2145                    Arc::clone(&self.sem),
2146                    &ctx,
2147                    &mut self.state,
2148                    fallback_attempt,
2149                )
2150                .await;
2151                *seat = fallback_seat;
2152                *out = fallback_out;
2153            }
2154        }
2155    }
2156
2157    /// Ask an implement seat's own CLI to confirm what it started, once, when
2158    /// its reply reported a command whose completion status it never
2159    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2160    /// that does and does not mean.
2161    ///
2162    /// The completion contract this task asks for, extended to `implement`
2163    /// with the same signal `continue_fix_report` reads for the fixer,
2164    /// rather than a keyword search over the reply or a hard requirement on
2165    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2166    /// a candidate's CLI turn ended cleanly while a test run it had started
2167    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2168    /// named in it at all is untouched by this: `commands` is empty, so
2169    /// there is nothing to be unconfirmed.
2170    ///
2171    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2172    /// this is not about recovering edits that might already be on disk, it
2173    /// is about a result the seat itself never vouched for, which resuming
2174    /// asks for regardless of what the tree already holds. Bounded to one
2175    /// attempt for the same reason `resume_undelivered` is — this is the
2176    /// most expensive node in the graph — and a seat that still cannot
2177    /// confirm on that attempt is left as whatever its (possibly still
2178    /// unconfirmed) reply says; this does not invent a new "failed" reason
2179    /// for a candidate that otherwise produced a real, committed change.
2180    async fn resume_unconfirmed_commands(
2181        &mut self,
2182        results: &mut [(usize, SeatState, AgentOutcome)],
2183        sent: &[SeatJob],
2184        prompts: &Prompts,
2185        run_id: &str,
2186    ) {
2187        for (wi, seat, out) in results.iter_mut() {
2188            let AgentOutcome::Ok(o) = &*out else {
2189                continue;
2190            };
2191            if !has_unconfirmed_command(&o.commands) {
2192                continue;
2193            }
2194            let Some(job) = sent.get(*wi) else { continue };
2195            if !has_context(&job.spec, seat, job.sessions) {
2196                self.state.event(
2197                    "implement",
2198                    format!(
2199                        "{}: the reply named a command whose own CLI never confirmed the exit \
2200                         status of, but there is no session left to resume",
2201                        seat.key
2202                    ),
2203                );
2204                continue;
2205            }
2206            self.state.event(
2207                "implement",
2208                format!(
2209                    "{}: the reply named a command whose own CLI never confirmed the exit \
2210                     status of; resuming the conversation",
2211                    seat.key
2212                ),
2213            );
2214            let mut retry = job.clone();
2215            retry.seat = seat.clone();
2216            retry.prompt = prompt::resume_incomplete(
2217                "a command in your last reply had no confirmed exit status",
2218            );
2219            retry.timeout = retry_budget(job.timeout, true);
2220            retry.stem = format!("{}-confirm", job.stem);
2221            let cache = self.state.config.cache_dir();
2222            let ctx = WaveCtx {
2223                carry_seats: false,
2224                run: run_id,
2225                node: "implement",
2226                prompts,
2227                cache: cache.as_deref(),
2228                round: None,
2229            };
2230            let (resumed_seat, resumed) =
2231                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2232            *seat = resumed_seat;
2233            *out = resumed;
2234        }
2235    }
2236
2237    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2238    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2239    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2240    /// that motivated this.
2241    ///
2242    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2243    /// own nudge loop already covers for judge/review/vote seats, and not a
2244    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2245    /// implement seats: here the CLI turn genuinely finished while the node's
2246    /// own work — the fixer's account of what it did — had not. Gated purely
2247    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2248    /// reply, never on any wording in it, so a fixer whose valid, first-try
2249    /// `FixReport` happens to mention having waited on a background test is
2250    /// never resumed — the `Ok(report)` branch at the call site returns
2251    /// before this is ever invoked.
2252    ///
2253    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2254    /// attempt ([`retry_budget`]), nothing attempted once the session is
2255    /// gone, and a quota hit ends the loop immediately rather than retrying a
2256    /// rate limit that fails the same way again.
2257    async fn continue_fix_report(
2258        &mut self,
2259        mut seat: SeatState,
2260        parse_err: String,
2261        job: &SeatJob,
2262        prompts: &Prompts,
2263        run_id: &str,
2264        round: usize,
2265    ) -> (
2266        SeatState,
2267        Option<FixReport>,
2268        Option<String>,
2269        ContinuationRecord,
2270    ) {
2271        let mut last_err = parse_err;
2272        let mut cumulative_wait_ms = 0u64;
2273        let mut attempts = 0usize;
2274        loop {
2275            if !has_context(&job.spec, &seat, job.sessions) {
2276                self.state.event(
2277                    "fix",
2278                    format!(
2279                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2280                         session left to resume into"
2281                    ),
2282                );
2283                let outcome = if attempts == 0 {
2284                    ContinuationOutcome::NoSession
2285                } else {
2286                    ContinuationOutcome::Exhausted
2287                };
2288                return (
2289                    seat,
2290                    None,
2291                    Some(format!("unparsable fix report: {last_err}")),
2292                    ContinuationRecord {
2293                        attempts,
2294                        cumulative_wait_ms,
2295                        outcome,
2296                    },
2297                );
2298            }
2299            if attempts >= MAX_FIX_CONTINUATIONS {
2300                self.state.event(
2301                    "fix",
2302                    format!(
2303                        "round {round}: fixer's reply still had no adoption report after \
2304                         {attempts} continuation(s) ({last_err}); giving up"
2305                    ),
2306                );
2307                return (
2308                    seat,
2309                    None,
2310                    Some(format!(
2311                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2312                    )),
2313                    ContinuationRecord {
2314                        attempts,
2315                        cumulative_wait_ms,
2316                        outcome: ContinuationOutcome::Exhausted,
2317                    },
2318                );
2319            }
2320            attempts += 1;
2321            self.state.event(
2322                "fix",
2323                format!(
2324                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2325                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2326                ),
2327            );
2328            let mut retry = job.clone();
2329            retry.seat = seat.clone();
2330            retry.prompt = prompt::resume_incomplete(&last_err);
2331            retry.timeout = retry_budget(job.timeout, true);
2332            retry.stem = format!("{}-continue{attempts}", job.stem);
2333            let cache = self.state.config.cache_dir();
2334            let ctx = WaveCtx {
2335                carry_seats: false,
2336                run: run_id,
2337                node: "fix",
2338                prompts,
2339                cache: cache.as_deref(),
2340                round: Some(round),
2341            };
2342            let (resumed_seat, resumed_out) = run_one(
2343                retry,
2344                Arc::clone(&self.sem),
2345                &ctx,
2346                &mut self.state,
2347                attempts,
2348            )
2349            .await;
2350            seat = resumed_seat;
2351            match resumed_out {
2352                AgentOutcome::Ok(o) => {
2353                    cumulative_wait_ms += o.duration_ms;
2354                    match verdict::extract_json::<FixReport>(&o.text) {
2355                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2356                            self.state.event(
2357                                "fix",
2358                                format!(
2359                                    "round {round}: fixer's adoption report recovered after \
2360                                     {attempts} continuation(s)"
2361                                ),
2362                            );
2363                            return (
2364                                seat,
2365                                Some(report),
2366                                None,
2367                                ContinuationRecord {
2368                                    attempts,
2369                                    cumulative_wait_ms,
2370                                    outcome: ContinuationOutcome::Resumed,
2371                                },
2372                            );
2373                        }
2374                        // The report parsed, but this same reply's own
2375                        // CommandEvidence — the identical record `state.jobs`
2376                        // renders — names a command whose CLI never
2377                        // confirmed an exit status. Read together, that is
2378                        // not a resolved answer: keep nudging rather than
2379                        // accept a report standing next to a command the
2380                        // seat's own CLI cannot vouch for.
2381                        Ok(_) => {
2382                            last_err = "the reply parsed, but it reported a command whose own CLI \
2383                                 never confirmed an exit status"
2384                                .to_owned();
2385                        }
2386                        Err(e) => last_err = e.to_string(),
2387                    }
2388                }
2389                AgentOutcome::Quota(o) => {
2390                    cumulative_wait_ms += o.duration_ms;
2391                    self.state.quota.push(QuotaLoss {
2392                        seat: seat.key.clone(),
2393                        node: "fix".to_owned(),
2394                        at: Timestamp::now(),
2395                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2396                    });
2397                    self.state.event(
2398                        "fix",
2399                        format!(
2400                            "round {round}: continuation rate limited (quota); not retrying now"
2401                        ),
2402                    );
2403                    return (
2404                        seat,
2405                        None,
2406                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2407                        ContinuationRecord {
2408                            attempts,
2409                            cumulative_wait_ms,
2410                            outcome: ContinuationOutcome::QuotaLost,
2411                        },
2412                    );
2413                }
2414                AgentOutcome::Dropped(o) => {
2415                    cumulative_wait_ms += o.duration_ms;
2416                    let why = o
2417                        .dropped
2418                        .as_ref()
2419                        .map(|d| d.why.as_str())
2420                        .unwrap_or("the CLI ended the stream without delivering its answer");
2421                    last_err = format!("the CLI dropped the stream ({why})");
2422                }
2423                AgentOutcome::Failed(e) => last_err = e,
2424            }
2425        }
2426    }
2427
2428    fn after_implement(&mut self) -> Result<()> {
2429        // Scan every candidate patch once the set is complete.
2430        if self.state.leaks.is_empty() {
2431            let cfg = self.state.config.blind.clone();
2432            let mut leaks = Vec::new();
2433            for c in &self.state.candidates {
2434                let Some(patch) =
2435                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2436                else {
2437                    continue;
2438                };
2439                leaks.extend(blind::scan(
2440                    &format!("candidate {} patch", c.label),
2441                    &patch,
2442                    &cfg.vendor_tokens,
2443                ));
2444            }
2445            if !leaks.is_empty() {
2446                let summary = leaks
2447                    .iter()
2448                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2449                    .collect::<Vec<_>>()
2450                    .join(", ");
2451                match cfg.on_leak {
2452                    LeakPolicy::Fail => {
2453                        self.state.status = RunStatus::Failed;
2454                        self.state
2455                            .event("blind", format!("vendor text in a patch: {summary}"));
2456                        self.state.leaks = leaks;
2457                        self.state.save()?;
2458                        self.settle_questions();
2459                        bail!(
2460                            "blind.on_leak = \"fail\" and vendor text reached a \
2461                             judged patch: {summary}"
2462                        );
2463                    }
2464                    LeakPolicy::Redact => self.state.event(
2465                        "blind",
2466                        format!("redacting vendor text for judging: {summary}"),
2467                    ),
2468                    LeakPolicy::Warn => self.state.event(
2469                        "blind",
2470                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2471                    ),
2472                }
2473                self.state.leaks = leaks;
2474            }
2475        }
2476
2477        if self.state.viable().is_empty() {
2478            if self.state.all_candidates_verified_noop() {
2479                // Every candidate agreed, with evidence the adoption guard
2480                // accepted, that nothing belongs in this worktree. That is
2481                // not the same fact as a candidate that simply failed to
2482                // write anything, and settling it as an ordinary `Failed`
2483                // (see `SCHEMA`'s doc for schema 10) is what let two of
2484                // task 391f's attempts burn a retry each re-discovering the
2485                // same already-landed fix. Terminal either way, so `judge`
2486                // must never run over an empty candidate set — unlike the
2487                // `Failed` branch below this returns `Ok`, not an error:
2488                // nothing here failed.
2489                self.state.status = RunStatus::VerifiedNoop;
2490                self.state.save()?;
2491                self.settle_questions();
2492                return Ok(());
2493            }
2494            self.state.status = RunStatus::Failed;
2495            self.state.save()?;
2496            self.settle_questions();
2497            bail!("no candidate produced a change; nothing to judge");
2498        }
2499        self.state.status = RunStatus::Judging;
2500        self.state.save()?;
2501        Ok(())
2502    }
2503
2504    // --------------------------------------------------------------- judge
2505
2506    async fn judge(&mut self) -> Result<()> {
2507        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2508        // agent files with `magi task add` name the run that paid for it. The
2509        // prompt overlay is cloned alongside it because the waves borrow it
2510        // while `self` is mutably borrowed by the node's own bookkeeping.
2511        let run_id = self.state.id.clone();
2512        let prompts = self.state.config.prompts.clone();
2513        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2514            return Ok(());
2515        }
2516        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2517        if viable.len() == 1 {
2518            // Recorded so this is a one-time event: `judgements` stays empty
2519            // either way, which without this flag is indistinguishable from
2520            // "not yet judged" on the next reentry — and status is left
2521            // untouched, so a later node's conclusion (e.g. `Blocked` after
2522            // the review budget ran out) survives a resume instead of being
2523            // clobbered back to `Judging` by this node running again.
2524            self.state.judge_skipped = true;
2525            self.state.event(
2526                "judge",
2527                format!(
2528                    "only candidate {} produced a change; judging skipped",
2529                    viable[0].label
2530                ),
2531            );
2532            self.state.save()?;
2533            return Ok(());
2534        }
2535        self.state.status = RunStatus::Judging;
2536
2537        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2538        let language = self.state.config.graph.language.clone();
2539        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2540        let sessions = self.state.config.graph.sessions;
2541        let artifacts = agent::artifacts_dir(&self.state.dir());
2542        let root = self.state.worktree_root();
2543        let base_short = short(&self.state.base_commit);
2544
2545        let mut jobs = Vec::new();
2546        let mut orders = Vec::new();
2547        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2548            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2549            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2550            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2551            let seat_key = format!("judge-{}", j + 1);
2552            let seat = self.seat(&seat_key, &spec.id);
2553            jobs.push(SeatJob {
2554                prompt: prompt::judge(
2555                    &self.state.instruction,
2556                    &views,
2557                    self.roles.judges.len(),
2558                    &base_short,
2559                    &language,
2560                ),
2561                spec,
2562                seat,
2563                cwd: root.join(format!("judge-{}", j + 1)),
2564                timeout,
2565                allow_write: false,
2566                sessions,
2567                artifacts: artifacts.clone(),
2568                stem: format!("judge-{}", j + 1),
2569                handover: None,
2570            });
2571        }
2572
2573        self.state.event(
2574            "judge",
2575            format!(
2576                "{} judges ranking {} candidates blind",
2577                jobs.len(),
2578                viable.len()
2579            ),
2580        );
2581        let labels_for_check = labels.clone();
2582        let mut quota_losses = Vec::new();
2583        let cache = self.state.config.cache_dir();
2584        let ctx = WaveCtx {
2585            carry_seats: false,
2586            run: &run_id,
2587            node: "judge",
2588            prompts: &prompts,
2589            cache: cache.as_deref(),
2590            round: None,
2591        };
2592        let results = ask_json_wave::<Ranking>(
2593            jobs,
2594            Arc::clone(&self.sem),
2595            self.state.config.graph.retries,
2596            &self.roles.judge_roster,
2597            &ctx,
2598            &mut quota_losses,
2599            &mut self.state,
2600            &move |r: &Ranking| r.validate(&labels_for_check),
2601        )
2602        .await;
2603        self.state.quota.extend(quota_losses);
2604
2605        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2606            let agent_id = seat.agent.clone();
2607            self.state.seats.insert(seat.key.clone(), seat);
2608            let mut record = Judgement {
2609                judge: j + 1,
2610                seat: format!("judge-{}", j + 1),
2611                agent: agent_id,
2612                ranking: Vec::new(),
2613                reasons: BTreeMap::new(),
2614                confidence: None,
2615                order: orders[j].clone(),
2616                failed: None,
2617                duration_ms: 0,
2618            };
2619            match res {
2620                Ok((ranking, out)) => {
2621                    record.ranking = ranking.normalized();
2622                    record.reasons = ranking.reasons;
2623                    record.confidence = ranking.confidence;
2624                    record.duration_ms = out.duration_ms;
2625                    self.state.event(
2626                        "judge",
2627                        format!(
2628                            "judge {} ranked {}",
2629                            j + 1,
2630                            record.ranking.iter().collect::<String>()
2631                        ),
2632                    );
2633                }
2634                Err(e) => {
2635                    record.failed = Some(e.to_string());
2636                    self.state
2637                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2638                }
2639            }
2640            self.state.judgements.push(record);
2641            self.state.save()?;
2642        }
2643        Ok(())
2644    }
2645
2646    // ---------------------------------------------------------- deliberate
2647
2648    async fn deliberate(&mut self) -> Result<()> {
2649        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2650        // agent files with `magi task add` name the run that paid for it. The
2651        // prompt overlay is cloned alongside it because the waves borrow it
2652        // while `self` is mutably borrowed by the node's own bookkeeping.
2653        let run_id = self.state.id.clone();
2654        let prompts = self.state.config.prompts.clone();
2655        if !self.state.deliberation.is_empty() {
2656            return Ok(());
2657        }
2658        let tops: Vec<char> = self
2659            .state
2660            .judgements
2661            .iter()
2662            .filter_map(|j| j.ranking.first().copied())
2663            .collect();
2664        let rounds = self.state.config.graph.deliberate_rounds;
2665        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2666            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2667                self.state.event(
2668                    "deliberate",
2669                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2670                );
2671            }
2672            self.state.status = RunStatus::Voting;
2673            self.state.save()?;
2674            return Ok(());
2675        }
2676
2677        self.state.status = RunStatus::Deliberating;
2678        self.state.event(
2679            "deliberate",
2680            format!(
2681                "split: first choices were {} — opening {rounds} round(s)",
2682                tops.iter().collect::<String>()
2683            ),
2684        );
2685
2686        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2687        let language = self.state.config.graph.language.clone();
2688        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2689        let sessions = self.state.config.graph.sessions;
2690        let artifacts = agent::artifacts_dir(&self.state.dir());
2691        let root = self.state.worktree_root();
2692        let base_short = short(&self.state.base_commit);
2693
2694        // Judges argue in sequence so that a turn can answer the one before it;
2695        // that is the difference between deliberation and three parallel
2696        // monologues.
2697        for round in 1..=rounds {
2698            let mut turns: Vec<DeliberationTurn> = Vec::new();
2699            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2700                if self.state.judgements[j].failed.is_some() {
2701                    continue;
2702                }
2703                let seat_key = format!("judge-{}", j + 1);
2704                let spec = self.occupant(&seat_key, spec);
2705                let mut seat = self.seat(&seat_key, &spec.id);
2706                let transcript = self.transcript(&turns, j);
2707                let build = |context: Option<&str>| {
2708                    prompt::deliberate(
2709                        &self.state.instruction,
2710                        context,
2711                        &transcript,
2712                        round,
2713                        rounds,
2714                        &language,
2715                    )
2716                };
2717                let block = self.candidate_block(&viable, &base_short);
2718                let full = build(Some(&block));
2719                let text = if has_context(&spec, &seat, sessions) {
2720                    build(None)
2721                } else {
2722                    full.clone()
2723                };
2724                let job = SeatJob {
2725                    spec,
2726                    seat: seat.clone(),
2727                    prompt: text,
2728                    cwd: root.join(format!("judge-{}", j + 1)),
2729                    timeout,
2730                    allow_write: false,
2731                    sessions,
2732                    artifacts: artifacts.clone(),
2733                    stem: format!("delib-{round}-judge-{}", j + 1),
2734                    handover: Some(full),
2735                };
2736                let cache = self.state.config.cache_dir();
2737                let ctx = WaveCtx {
2738                    carry_seats: false,
2739                    run: &run_id,
2740                    node: "deliberate",
2741                    prompts: &prompts,
2742                    cache: cache.as_deref(),
2743                    round: None,
2744                };
2745                // A turn is never nudged (`retries` 0): a failed seat is
2746                // handed to the next roster agent, which gets the full
2747                // context. An empty answer is a turn, not a failure.
2748                let mut losses = Vec::new();
2749                let mut results = ask_wave_with::<String>(
2750                    vec![job],
2751                    Arc::clone(&self.sem),
2752                    0,
2753                    &self.roles.judge_roster,
2754                    &ctx,
2755                    &mut losses,
2756                    &mut self.state,
2757                    &|text: &str| {
2758                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2759                    },
2760                )
2761                .await;
2762                self.state.quota.extend(losses);
2763                let (updated, res, _) = results.pop().expect("one job in, one result out");
2764                seat = updated;
2765                let agent_id = seat.agent.clone();
2766                self.state.seats.insert(seat.key.clone(), seat);
2767                let body = match res {
2768                    Ok((body, _)) => body,
2769                    // Skip the seat; a CLI's raw error JSON is never read as
2770                    // this judge's position.
2771                    Err(e) => {
2772                        self.state
2773                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2774                        continue;
2775                    }
2776                };
2777                let tentative = verdict::extract_json::<Position>(&body)
2778                    .ok()
2779                    .and_then(|p| p.tentative)
2780                    .and_then(|s| s.trim().chars().next())
2781                    .map(|c| c.to_ascii_uppercase());
2782                self.state.event(
2783                    "deliberate",
2784                    format!(
2785                        "round {round}: judge {} now favours {}",
2786                        j + 1,
2787                        tentative.map_or("—".to_owned(), |c| c.to_string())
2788                    ),
2789                );
2790                turns.push(DeliberationTurn {
2791                    judge: j + 1,
2792                    agent: agent_id,
2793                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2794                    tentative,
2795                });
2796            }
2797            self.state
2798                .deliberation
2799                .push(DeliberationRound { round, turns });
2800            self.state.save()?;
2801        }
2802
2803        self.state.status = RunStatus::Voting;
2804        self.state.save()?;
2805        Ok(())
2806    }
2807
2808    // ---------------------------------------------------------------- vote
2809
2810    async fn vote(&mut self) -> Result<()> {
2811        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2812        // agent files with `magi task add` name the run that paid for it. The
2813        // prompt overlay is cloned alongside it because the waves borrow it
2814        // while `self` is mutably borrowed by the node's own bookkeeping.
2815        let run_id = self.state.id.clone();
2816        let prompts = self.state.config.prompts.clone();
2817        if !self.state.votes.is_empty() {
2818            return Ok(());
2819        }
2820        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2821        if viable.len() == 1 {
2822            return Ok(());
2823        }
2824        self.state.status = RunStatus::Voting;
2825
2826        let language = self.state.config.graph.language.clone();
2827        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2828        let sessions = self.state.config.graph.sessions;
2829        let artifacts = agent::artifacts_dir(&self.state.dir());
2830        let root = self.state.worktree_root();
2831        let base_short = short(&self.state.base_commit);
2832        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2833
2834        let mut jobs = Vec::new();
2835        let mut seats_at = Vec::new();
2836        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2837            if self
2838                .state
2839                .judgements
2840                .get(j)
2841                .is_some_and(|r| r.failed.is_some())
2842            {
2843                continue;
2844            }
2845            let seat_key = format!("judge-{}", j + 1);
2846            let spec = self.occupant(&seat_key, spec);
2847            let seat = self.seat(&seat_key, &spec.id);
2848            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2849            let text = if has_context(&spec, &seat, sessions) {
2850                prompt::final_vote(&viable, &language)
2851            } else {
2852                full.clone()
2853            };
2854            jobs.push(SeatJob {
2855                spec,
2856                seat,
2857                prompt: text,
2858                cwd: root.join(format!("judge-{}", j + 1)),
2859                timeout,
2860                allow_write: false,
2861                sessions,
2862                artifacts: artifacts.clone(),
2863                stem: format!("vote-judge-{}", j + 1),
2864                handover: Some(full),
2865            });
2866            seats_at.push(j);
2867        }
2868
2869        self.state.event(
2870            "vote",
2871            format!(
2872                "collecting {} final votes one by one, privately",
2873                jobs.len()
2874            ),
2875        );
2876        let allowed = viable.clone();
2877        let mut quota_losses = Vec::new();
2878        let cache = self.state.config.cache_dir();
2879        let ctx = WaveCtx {
2880            carry_seats: false,
2881            run: &run_id,
2882            node: "vote",
2883            prompts: &prompts,
2884            cache: cache.as_deref(),
2885            round: None,
2886        };
2887        let results = ask_json_wave::<FinalVote>(
2888            jobs,
2889            Arc::clone(&self.sem),
2890            self.state.config.graph.retries,
2891            &self.roles.judge_roster,
2892            &ctx,
2893            &mut quota_losses,
2894            &mut self.state,
2895            &move |v: &FinalVote| match v.label() {
2896                Some(c) if allowed.contains(&c) => Ok(()),
2897                other => bail!("vote {other:?} is not one of {allowed:?}"),
2898            },
2899        )
2900        .await;
2901        self.state.quota.extend(quota_losses);
2902
2903        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2904            let agent_id = seat.agent.clone();
2905            self.state.seats.insert(seat.key.clone(), seat);
2906            let initial = self
2907                .state
2908                .judgements
2909                .get(j)
2910                .and_then(|r| r.ranking.first().copied());
2911            let mut record = VoteRecord {
2912                judge: j + 1,
2913                agent: agent_id,
2914                vote: None,
2915                reason: String::new(),
2916                changed: false,
2917            };
2918            match res {
2919                Ok((v, _)) => {
2920                    record.vote = v.label();
2921                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2922                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2923                    self.state.event(
2924                        "vote",
2925                        format!(
2926                            "judge {} voted {}{}",
2927                            j + 1,
2928                            record.vote.unwrap_or('?'),
2929                            if record.changed { " (changed)" } else { "" }
2930                        ),
2931                    );
2932                }
2933                Err(e) => {
2934                    self.state
2935                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2936                }
2937            }
2938            self.state.votes.push(record);
2939            self.state.save()?;
2940        }
2941        Ok(())
2942    }
2943
2944    // --------------------------------------------------------------- tally
2945
2946    fn tally(&mut self) -> Result<()> {
2947        if self.state.tally.is_some() {
2948            return Ok(());
2949        }
2950        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2951        let tops: Vec<char> = self
2952            .state
2953            .judgements
2954            .iter()
2955            .filter_map(|j| j.ranking.first().copied())
2956            .collect();
2957        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2958
2959        // A judge whose private vote failed still counted once, in the initial
2960        // ranking; using it beats discarding a whole seat.
2961        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2962        let mut cast: Vec<char> = Vec::new();
2963        for (i, j) in self.state.judgements.iter().enumerate() {
2964            let vote = self
2965                .state
2966                .votes
2967                .iter()
2968                .find(|v| v.judge == i + 1)
2969                .and_then(|v| v.vote)
2970                .or_else(|| j.ranking.first().copied());
2971            if let Some(v) = vote {
2972                *first_choice.entry(v).or_insert(0) += 1;
2973                cast.push(v);
2974            }
2975        }
2976
2977        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2978        for j in &self.state.judgements {
2979            let n = j.ranking.len();
2980            for (pos, label) in j.ranking.iter().enumerate() {
2981                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2982            }
2983        }
2984
2985        let best = first_choice.values().copied().max().unwrap_or(0);
2986        let mut leaders: Vec<char> = first_choice
2987            .iter()
2988            .filter(|(_, v)| **v == best)
2989            .map(|(k, _)| *k)
2990            .collect();
2991        let mut tie_break = None;
2992        if leaders.len() > 1 {
2993            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2994            let borda_leaders: Vec<char> = leaders
2995                .iter()
2996                .copied()
2997                .filter(|l| borda[l] == top_borda)
2998                .collect();
2999            tie_break = Some(if borda_leaders.len() == 1 {
3000                format!(
3001                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3002                    leaders.len()
3003                )
3004            } else {
3005                format!(
3006                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3007                    leaders.len()
3008                )
3009            });
3010            leaders = borda_leaders;
3011            leaders.sort_unstable();
3012        }
3013        let winner = *leaders
3014            .first()
3015            .or(viable.first())
3016            .context("no candidate to declare a winner from")?;
3017
3018        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3019        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3020        let deliberated = !self.state.deliberation.is_empty();
3021
3022        // Whose verdict is this? A rate-limited seat is absent even if it
3023        // ranked before the limit hit, so presence is measured against the
3024        // recorded losses, not just "did a ranking ever appear".
3025        let quota_seats: std::collections::BTreeSet<&str> =
3026            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3027        let mut present = 0usize;
3028        for (i, j) in self.state.judgements.iter().enumerate() {
3029            if quota_seats.contains(j.seat.as_str()) {
3030                continue;
3031            }
3032            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3033            let voted = self
3034                .state
3035                .votes
3036                .iter()
3037                .any(|v| v.judge == i + 1 && v.vote.is_some());
3038            if ranked || voted {
3039                present += 1;
3040            }
3041        }
3042        // Strict majority of the configured panel. A bare majority is real
3043        // signal we can act on, while a minority verdict must never stand in
3044        // for a healthy one. A one-candidate run needs no panel at all, and
3045        // `judges` stays `0` rather than the roster size a panel that never
3046        // sat would otherwise be credited with.
3047        let needs_quorum = viable.len() > 1;
3048        let judges_total = if needs_quorum {
3049            self.roles.judges.len()
3050        } else {
3051            0
3052        };
3053        let quorum = if needs_quorum {
3054            judges_total / 2 + 1
3055        } else {
3056            0
3057        };
3058        let met_quorum = !needs_quorum || present >= quorum;
3059        let uncontested = (!needs_quorum).then(|| {
3060            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3061        });
3062
3063        self.state.event(
3064            "tally",
3065            match &uncontested {
3066                Some(reason) => format!("winner {winner} — {reason}"),
3067                None => format!(
3068                    "winner {winner} — votes {} | initial {} | {} changed | \
3069                     {present}/{judges_total} judges{}",
3070                    first_choice
3071                        .iter()
3072                        .map(|(k, v)| format!("{k}:{v}"))
3073                        .collect::<Vec<_>>()
3074                        .join(" "),
3075                    if unanimous_initial {
3076                        "unanimous"
3077                    } else {
3078                        "split"
3079                    },
3080                    changed_votes,
3081                    if met_quorum {
3082                        String::new()
3083                    } else {
3084                        format!(" — below quorum ({quorum} required)")
3085                    },
3086                ),
3087            },
3088        );
3089        if !met_quorum {
3090            self.state.event(
3091                "stall",
3092                format!(
3093                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3094                     the run stops here, resumable"
3095                ),
3096            );
3097        }
3098        self.state.tally = Some(Tally {
3099            first_choice,
3100            borda,
3101            winner,
3102            rankings: tops.len(),
3103            unanimous_initial,
3104            deliberated,
3105            changed_votes,
3106            unanimous_final,
3107            tie_break,
3108            judges: judges_total,
3109            present,
3110            quorum,
3111            met_quorum,
3112            uncontested,
3113        });
3114        self.state.status = if met_quorum {
3115            RunStatus::Reviewing
3116        } else {
3117            RunStatus::Stalled
3118        };
3119        self.state.save()?;
3120        Ok(())
3121    }
3122
3123    // ------------------------------------------------------------- recover
3124
3125    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3126    /// resumed toward completion once the transient cause clears.
3127    ///
3128    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3129    /// it toward the quorum, which is exactly the set of seats whose absence
3130    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3131    /// not depend on which node happened to hit the limit), or an ordinary
3132    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3133    /// seat is never disturbed.
3134    ///
3135    /// A seat that now answers with a usable ranking is "recovered": its
3136    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3137    /// `tally` counts it present again), and its vote re-collected. A seat that
3138    /// still fails keeps its loss and stays absent.
3139    ///
3140    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3141    /// to review/gate/merge), `false` when it is still below quorum (the run
3142    /// stays `Stalled`, still resumable for a later retry).
3143    #[allow(clippy::too_many_lines)]
3144    async fn recover_stall(&mut self) -> Result<bool> {
3145        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3146        // agent files with `magi task add` name the run that paid for it. The
3147        // prompt overlay is cloned alongside it because the waves borrow it
3148        // while `self` is mutably borrowed by the node's own bookkeeping.
3149        let run_id = self.state.id.clone();
3150        let prompts = self.state.config.prompts.clone();
3151        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3152        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3153        // is what keeps a non-quota collapse — or a quota loss recorded at the
3154        // deliberate node — from being a permanent dead-end on `--resume`.
3155        let quota_seats: BTreeSet<&str> =
3156            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3157        let absent: Vec<String> = self
3158            .state
3159            .judgements
3160            .iter()
3161            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3162            .map(|j| j.seat.clone())
3163            .collect();
3164        if absent.is_empty() {
3165            return Ok(false);
3166        }
3167        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3168        if viable.len() <= 1 {
3169            return Ok(false);
3170        }
3171        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3172        let language = self.state.config.graph.language.clone();
3173        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3174        let sessions = self.state.config.graph.sessions;
3175        let artifacts = agent::artifacts_dir(&self.state.dir());
3176        let root = self.state.worktree_root();
3177        let base_short = short(&self.state.base_commit);
3178        let candidates: Vec<Candidate> = viable.clone();
3179
3180        // Map each absent seat key to its 0-based position in `roles.judges`.
3181        let mut positions: Vec<usize> = absent
3182            .iter()
3183            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3184            .collect();
3185        if positions.is_empty() {
3186            return Ok(false);
3187        }
3188        positions.sort_unstable();
3189        positions.dedup();
3190
3191        // Re-rank the lost seats, one blind prompt each.
3192        let mut judge_jobs = Vec::new();
3193        for &j in &positions {
3194            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3195            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3196            let seat_key = format!("judge-{}", j + 1);
3197            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3198            let seat = self.seat(&seat_key, &spec.id);
3199            judge_jobs.push(SeatJob {
3200                spec,
3201                seat,
3202                prompt: prompt::judge(
3203                    &self.state.instruction,
3204                    &views,
3205                    self.roles.judges.len(),
3206                    &base_short,
3207                    &language,
3208                ),
3209                cwd: root.join(seat_key),
3210                timeout,
3211                allow_write: false,
3212                sessions,
3213                artifacts: artifacts.clone(),
3214                stem: format!("judge-{}-recover", j + 1),
3215                handover: None,
3216            });
3217        }
3218
3219        let labels_for_check = labels.clone();
3220        let mut judge_losses = Vec::new();
3221        let retries = self.state.config.graph.retries;
3222        let cache = self.state.config.cache_dir();
3223        let ctx = WaveCtx {
3224            carry_seats: false,
3225            run: &run_id,
3226            node: "judge",
3227            prompts: &prompts,
3228            cache: cache.as_deref(),
3229            round: None,
3230        };
3231        let results = ask_json_wave::<Ranking>(
3232            judge_jobs,
3233            Arc::clone(&self.sem),
3234            retries,
3235            &self.roles.judge_roster,
3236            &ctx,
3237            &mut judge_losses,
3238            &mut self.state,
3239            &move |r: &Ranking| r.validate(&labels_for_check),
3240        )
3241        .await;
3242
3243        // Refresh the judgement of every seat that ranked again.
3244        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3245        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3246            let agent_id = seat.agent.clone();
3247            self.state.seats.insert(seat.key.clone(), seat);
3248            let record = &mut self.state.judgements[j];
3249            match res {
3250                Ok((ranking, out)) => {
3251                    record.agent = agent_id;
3252                    record.ranking = ranking.normalized();
3253                    record.reasons = ranking.reasons;
3254                    record.confidence = ranking.confidence;
3255                    record.failed = None;
3256                    record.duration_ms = out.duration_ms;
3257                    recovered.insert(j);
3258                    self.state.event(
3259                        "recover",
3260                        format!("judge {} ranked again after the limit", j + 1),
3261                    );
3262                }
3263                Err(e) => {
3264                    self.state
3265                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3266                }
3267            }
3268        }
3269
3270        // Re-ask the votes of the seats that recovered a ranking.
3271        let mut vote_jobs = Vec::new();
3272        let mut vote_pos: Vec<usize> = Vec::new();
3273        for &j in &recovered {
3274            let seat_key = format!("judge-{}", j + 1);
3275            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3276            let seat = self.seat(&seat_key, &spec.id);
3277            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3278            let text = if has_context(&spec, &seat, sessions) {
3279                prompt::final_vote(&labels, &language)
3280            } else {
3281                full.clone()
3282            };
3283            vote_jobs.push(SeatJob {
3284                spec,
3285                seat,
3286                prompt: text,
3287                cwd: root.join(seat_key),
3288                timeout,
3289                allow_write: false,
3290                sessions,
3291                artifacts: artifacts.clone(),
3292                stem: format!("vote-judge-{}-recover", j + 1),
3293                handover: Some(full),
3294            });
3295            vote_pos.push(j);
3296        }
3297        let allowed = labels.clone();
3298        let mut vote_losses = Vec::new();
3299        let vote_retries = self.state.config.graph.retries;
3300        let vote_cache = self.state.config.cache_dir();
3301        let ctx = WaveCtx {
3302            carry_seats: false,
3303            run: &run_id,
3304            node: "vote",
3305            prompts: &prompts,
3306            cache: vote_cache.as_deref(),
3307            round: None,
3308        };
3309        let votes = ask_json_wave::<FinalVote>(
3310            vote_jobs,
3311            Arc::clone(&self.sem),
3312            vote_retries,
3313            &self.roles.judge_roster,
3314            &ctx,
3315            &mut vote_losses,
3316            &mut self.state,
3317            &move |v: &FinalVote| match v.label() {
3318                Some(c) if allowed.contains(&c) => Ok(()),
3319                other => bail!("vote {other:?} is not one of {allowed:?}"),
3320            },
3321        )
3322        .await;
3323        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3324            let agent_id = seat.agent.clone();
3325            self.state.seats.insert(seat.key.clone(), seat);
3326            match res {
3327                Ok((v, _)) => {
3328                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3329                        rec.vote = v.label();
3330                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3331                    } else {
3332                        self.state.votes.push(VoteRecord {
3333                            judge: j + 1,
3334                            agent: agent_id,
3335                            vote: v.label(),
3336                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3337                            changed: false,
3338                        });
3339                    }
3340                    self.state.event(
3341                        "recover",
3342                        format!("judge {} voted again after the limit", j + 1),
3343                    );
3344                }
3345                Err(e) => {
3346                    self.state
3347                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3348                }
3349            }
3350        }
3351
3352        // A seat that ranked again is present even if its re-vote failed —
3353        // `tally` falls back to the initial ranking's first choice — so clear
3354        // its quota loss. Seats that still fail keep theirs and stay absent.
3355        let recovered_keys: BTreeSet<String> = recovered
3356            .iter()
3357            .map(|&j| format!("judge-{}", j + 1))
3358            .collect();
3359        self.state
3360            .quota
3361            .retain(|q| !recovered_keys.contains(&q.seat));
3362        // A seat that hit the limit again is a fresh loss, not the old one:
3363        // replace the stale entry so the history stays one-per-seat and the
3364        // daemon can tell this attempt's loss from a previous session's.
3365        for loss in judge_losses.into_iter().chain(vote_losses) {
3366            if recovered_keys.contains(&loss.seat) {
3367                continue;
3368            }
3369            self.state.quota.retain(|q| q.seat != loss.seat);
3370            self.state.quota.push(loss);
3371        }
3372
3373        // Recompute the verdict from the refreshed panel.
3374        self.state.tally = None;
3375        self.tally()?;
3376        Ok(self
3377            .state
3378            .tally
3379            .as_ref()
3380            .map(|t| t.met_quorum)
3381            .unwrap_or(false))
3382    }
3383
3384    // ----------------------------------------------------------------- fold
3385
3386    async fn fold_losers(&mut self) -> Result<()> {
3387        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3388            return Ok(());
3389        };
3390        let repo = self.state.repo.clone();
3391        let mut folded = Vec::new();
3392        for i in 0..self.state.candidates.len() {
3393            let c = &self.state.candidates[i];
3394            if c.label == winner || c.folded {
3395                continue;
3396            }
3397            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3398            git::worktree_remove(&repo, &wt).await.ok();
3399            git::branch_delete(&repo, &branch).await.ok();
3400            self.state.candidates[i].folded = true;
3401            folded.push(label.to_string());
3402        }
3403        // The judges are finished; their checkouts are pure cost from here.
3404        let root = self.state.worktree_root();
3405        for j in 1..=self.roles.judges.len() {
3406            let wt = root.join(format!("judge-{j}"));
3407            if wt.exists() {
3408                git::worktree_remove(&repo, &wt).await.ok();
3409            }
3410        }
3411        // The design-deliberation stage is finished by the time a tally
3412        // exists — same reasoning as the judges above.
3413        if self.state.config.graph.advise {
3414            for k in 1..=self.state.config.graph.advisors {
3415                let wt = root.join(format!("advisor-{k}"));
3416                if wt.exists() {
3417                    git::worktree_remove(&repo, &wt).await.ok();
3418                }
3419            }
3420        }
3421        if !folded.is_empty() {
3422            self.state
3423                .event("fold", format!("folded candidates {}", folded.join(", ")));
3424            self.state.save()?;
3425        }
3426        Ok(())
3427    }
3428
3429    // ------------------------------------------------------------ base sync
3430
3431    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3432    /// anything verifies it.
3433    ///
3434    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3435    /// read whatever is checked out in the winner's worktree. Left alone that
3436    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3437    /// it when the run *branched* - and a run takes long enough that the base
3438    /// has usually moved by the time it gets here. A gate that ran there
3439    /// answers "green on the commit this run started from", not "green on
3440    /// what is about to land", and the difference showed up three times in
3441    /// one day as a green run whose merge would have reverted a file another
3442    /// pull request had already landed.
3443    ///
3444    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3445    /// `land::Step::Rebase` calls, rather than a second implementation of the
3446    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3447    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3448    /// doc warns about ("two answers to a question nobody notices until a
3449    /// diff is wrong").
3450    ///
3451    /// A conflict is not the end of the road: the standing rebase is handed
3452    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3453    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3454    /// land). Once it finishes, review and the gate run as usual on the
3455    /// rebased tree, which is where a breakage the new base caused is caught
3456    /// by the ordinary gate-fix round. magi resolves nothing itself.
3457    ///
3458    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3459    /// `state.base_sync.attempts`, is how many times the base is *rebased
3460    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3461    /// *conflict* was given to a fixer. When the fixer cannot finish the
3462    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3463    /// what was tried (rounds spent, paths still conflicted) and the branch
3464    /// and worktree stay exactly as they were - untouched, for a person to
3465    /// look at - which is also what makes re-entering this function
3466    /// afterwards a no-op instead of a second attempt at the same wall. A
3467    /// push failure ends the same way.
3468    async fn sync_to_base(&mut self) -> Result<()> {
3469        if self.state.status == RunStatus::AlreadyInBase {
3470            return Ok(());
3471        }
3472        let conflicted = self
3473            .state
3474            .base_sync
3475            .as_ref()
3476            .is_some_and(|s| s.conflict.is_some());
3477        let Some(winner) = self.state.winner().cloned() else {
3478            return Ok(());
3479        };
3480
3481        let repo = self.state.repo.clone();
3482        let remote = self.state.config.merge.remote.clone();
3483        let base_branch = self.state.base_branch.clone();
3484        let tracking = format!("{remote}/{base_branch}");
3485
3486        git::fetch(&repo, &remote, &base_branch).await.ok();
3487        // No network, or the remote never had this branch: `resolve_base`
3488        // already treats that as non-fatal at branch time, and a run that got
3489        // this far must not be blocked by it here either.
3490        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3491            return Ok(());
3492        };
3493
3494        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3495        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3496        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3497
3498        // Before any rebase, and before a recorded conflict is honoured: a
3499        // branch whose change reached the base under other commit ids has
3500        // nothing to rebase and nothing to conflict with, and a run that
3501        // already stopped on that phantom conflict recovers here on resume.
3502        // `behind == 0` with head == tip is a branch the base has since taken
3503        // in whole, whether or not a conflict was ever recorded: the ancestry
3504        // proof must still run (`classify` ignores a head still on the start
3505        // commit).
3506        if (behind > 0 || conflicted || head == tip)
3507            && self
3508                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3509                .await?
3510        {
3511            return Ok(());
3512        }
3513        if conflicted {
3514            return Ok(());
3515        }
3516
3517        if behind == 0 {
3518            // A fixer-finished rebase moves the branch ref before the
3519            // winner's worktree is told (`sync_to_head` below). A run that
3520            // died in between resumes here with `behind == 0` and a tree still
3521            // holding the pre-rebase files, which review and the gate would
3522            // then read. That state is exactly: HEAD moved off the tip the
3523            // rebase started from, yet the tree is still identical to that
3524            // tip. A tree with edits of its own differs from it, so nothing
3525            // is thrown away.
3526            if let Some(from) = self
3527                .state
3528                .rebase_fixes
3529                .iter()
3530                .rev()
3531                .find_map(|r| r.from.clone())
3532                && from != head
3533                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3534                    .await
3535                    .is_ok_and(|o| o.ok())
3536            {
3537                git::sync_to_head(&winner.worktree).await?;
3538            }
3539            // An earlier attempt may have rebased the branch locally and died
3540            // before pushing it (only the fresh-rebase arm below pushes).
3541            // Publish it now, so the plain push at PR time is not refused as
3542            // a non-fast-forward. A run already holding a recorded conflict
3543            // never reaches here; that case is out of scope.
3544            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3545            if let Some(why) = &conflict {
3546                self.state.status = RunStatus::Blocked;
3547                self.state.event("land", why.clone());
3548            }
3549            self.state.base_sync = Some(BaseSync {
3550                tip,
3551                behind: 0,
3552                attempts,
3553                conflict,
3554                already_in: None,
3555            });
3556            self.state.save()?;
3557            return Ok(());
3558        }
3559
3560        if attempts >= BASE_SYNC_ROUNDS {
3561            let why = format!(
3562                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3563                 rebase(s); rebasing again would only race it",
3564                winner.branch
3565            );
3566            self.state.status = RunStatus::Blocked;
3567            self.state.base_sync = Some(BaseSync {
3568                tip,
3569                behind,
3570                attempts,
3571                conflict: Some(why.clone()),
3572                already_in: None,
3573            });
3574            self.state.event("land", why);
3575            self.state.save()?;
3576            return Ok(());
3577        }
3578
3579        self.state.event(
3580            "land",
3581            format!(
3582                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3583                winner.branch
3584            ),
3585        );
3586        self.state.save()?;
3587
3588        // The remote's copy of the branch, read now and only if the fetch
3589        // really succeeded (a stale tracking ref must never pin a lease). It is
3590        // pushed over after a rebase only when it is a commit this branch
3591        // already contains, by ancestry or by patch (an earlier rebase of ours
3592        // that never reached the remote): anything else is somebody else's work.
3593        let branch_tracking = format!("{remote}/{}", winner.branch);
3594        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3595        let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3596            git::rev_parse(&repo, &branch_tracking).await.ok()
3597        } else {
3598            None
3599        };
3600        // A remote tip this branch does not contain is somebody else's work:
3601        // rebasing would leave a local tip that can never be pushed. Stop
3602        // before touching anything and say so.
3603        if let Some(theirs) = &remote_tip
3604            && !git::is_ancestor(&repo, theirs, &head).await
3605            && !crate::reconcile::origin_missing(&repo, &head, theirs)
3606                .await
3607                .is_ok_and(|missing| missing.is_empty())
3608        {
3609            let why = format!(
3610                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3611                 them",
3612                short(theirs),
3613                winner.branch
3614            );
3615            self.state.status = RunStatus::Blocked;
3616            self.state.base_sync = Some(BaseSync {
3617                tip,
3618                behind,
3619                attempts,
3620                conflict: Some(why.clone()),
3621                already_in: None,
3622            });
3623            self.state.event("land", why);
3624            self.state.save()?;
3625            return Ok(());
3626        }
3627
3628        let scratch = self.state.dir().join("base-sync");
3629        let rebased = match crate::rebase::rebase_with_fixer(
3630            &mut self.state,
3631            &scratch,
3632            &winner.branch,
3633            &tracking,
3634        )
3635        .await
3636        {
3637            Ok(crate::rebase::Rebased::Applied) => Ok(None),
3638            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3639            Err(e) => Err(e),
3640        };
3641        let attempts = attempts + 1;
3642        match rebased {
3643            Ok(None) => {
3644                // The branch ref moved, but a worktree that already had it
3645                // checked out (the winner's) was not told; sync its index and
3646                // files before anything reads them.
3647                git::sync_to_head(&winner.worktree).await?;
3648                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3649                let mut conflict = None;
3650                if let Some(pinned) = &remote_tip {
3651                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3652                    match pushed {
3653                        Ok(o) if o.ok() => self.state.event(
3654                            "land",
3655                            format!("pushed rebased {} to {remote}", winner.branch),
3656                        ),
3657                        Ok(o) => {
3658                            conflict = Some(format!(
3659                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
3660                                winner.branch,
3661                                short(pinned),
3662                                o.stderr.chars().take(600).collect::<String>()
3663                            ));
3664                        }
3665                        Err(e) => {
3666                            conflict = Some(format!(
3667                                "rebased {} locally but could not push it: {e:#}",
3668                                winner.branch
3669                            ));
3670                        }
3671                    }
3672                }
3673                if let Some(why) = &conflict {
3674                    self.state.status = RunStatus::Blocked;
3675                    self.state.event("land", why.clone());
3676                }
3677                self.state.base_sync = Some(BaseSync {
3678                    tip: tip.clone(),
3679                    behind: 0,
3680                    attempts,
3681                    conflict,
3682                    already_in: None,
3683                });
3684                self.state
3685                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
3686            }
3687            Ok(Some(conflict)) => {
3688                let why = format!(
3689                    "{} conflicts with {tracking} and did not rebase: {}",
3690                    winner.branch,
3691                    conflict.chars().take(600).collect::<String>()
3692                );
3693                self.state.status = RunStatus::Blocked;
3694                self.state.base_sync = Some(BaseSync {
3695                    tip,
3696                    behind,
3697                    attempts,
3698                    conflict: Some(why.clone()),
3699                    already_in: None,
3700                });
3701                self.state.event("land", why);
3702            }
3703            Err(e) => {
3704                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3705                self.state.status = RunStatus::Blocked;
3706                self.state.base_sync = Some(BaseSync {
3707                    tip,
3708                    behind,
3709                    attempts,
3710                    conflict: Some(why.clone()),
3711                    already_in: None,
3712                });
3713                self.state.event("land", why);
3714            }
3715        }
3716        self.state.save()?;
3717        Ok(())
3718    }
3719
3720    /// Push a branch an earlier attempt rebased locally but never published,
3721    /// pinned to the remote tip read right after a successful fetch. Returns
3722    /// the reason when the run must stop; `None` when there was nothing to do
3723    /// (no remote copy, the same tip, or a remote copy this branch already
3724    /// contains, which the PR-time push fast-forwards) or the push succeeded.
3725    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3726        let repo = self.state.repo.clone();
3727        let remote = self.state.config.merge.remote.clone();
3728        let fetched = git::fetch(&repo, &remote, branch).await;
3729        if !matches!(&fetched, Ok(o) if o.ok()) {
3730            return None;
3731        }
3732        let branch_tracking = format!("{remote}/{branch}");
3733        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3734        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3735            return None;
3736        }
3737        if !crate::reconcile::origin_missing(&repo, head, &theirs)
3738            .await
3739            .is_ok_and(|missing| missing.is_empty())
3740        {
3741            return Some(format!(
3742                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3743                 them",
3744                short(&theirs)
3745            ));
3746        }
3747        match git::push_pinned(&repo, &remote, branch, &theirs).await {
3748            Ok(o) if o.ok() => {
3749                self.state
3750                    .event("land", format!("pushed rebased {branch} to {remote}"));
3751                None
3752            }
3753            Ok(o) => Some(format!(
3754                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3755                 someone may have pushed): {}",
3756                short(&theirs),
3757                o.stderr.chars().take(600).collect::<String>()
3758            )),
3759            Err(e) => Some(format!(
3760                "{branch} is rebased locally but could not be pushed: {e:#}"
3761            )),
3762        }
3763    }
3764
3765    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
3766    /// is already on `tip` under other commit ids ([`crate::already`]); returns
3767    /// whether it did.
3768    ///
3769    /// Checked only when the base is ahead of the branch. A failing check is
3770    /// "not proven" - the ordinary rebase path then decides - never a reason to
3771    /// stop the run.
3772    ///
3773    /// The remote copy of the branch is held to the same standard as the local
3774    /// one: if it carries a tip this worktree does not, that tip must itself be
3775    /// proven in the base, or nothing is settled (a pull request would
3776    /// otherwise be closed over commits nobody checked). The pull request is
3777    /// closed *before* the terminal status is saved; if that fails for a
3778    /// reason other than a refusal (no network, a `gh` error) the run is left
3779    /// `Blocked` with the reason as its conflict, which a resume retries -
3780    /// the same recovery a phantom conflict gets.
3781    async fn settle_already_in(
3782        &mut self,
3783        branch: &str,
3784        tip: &str,
3785        head: &str,
3786        attempts: usize,
3787        behind: usize,
3788    ) -> Result<bool> {
3789        let repo = self.state.repo.clone();
3790        let remote = self.state.config.merge.remote.clone();
3791        let start = self.state.base_commit.clone();
3792        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3793            Ok(Some(e)) => e,
3794            Ok(None) => return Ok(false),
3795            Err(e) => {
3796                tracing::warn!("already-in-base check for {branch}: {e:#}");
3797                return Ok(false);
3798            }
3799        };
3800        let mut verified = vec![head.to_owned()];
3801        let fetched = git::fetch(&repo, &remote, branch).await;
3802        if matches!(&fetched, Ok(o) if o.ok())
3803            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3804            && theirs != head
3805        {
3806            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3807                Ok(Some(_)) => verified.push(theirs),
3808                _ => return Ok(false),
3809            }
3810        }
3811        let base_branch = self.state.base_branch.clone();
3812        let message = format!(
3813            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3814             land",
3815            evidence.names(),
3816            evidence.proof.as_str()
3817        );
3818        let closed =
3819            crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3820        match closed {
3821            Ok(Ok(url)) => self
3822                .state
3823                .event("land", format!("closed {url}: superseded on {base_branch}")),
3824            Ok(Err(why)) => self
3825                .state
3826                .event("land", format!("did not close a pull request: {why}")),
3827            Err(e) => {
3828                let why = format!(
3829                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
3830                     not be closed ({e:#}); resume to retry"
3831                );
3832                self.state.status = RunStatus::Blocked;
3833                self.state.base_sync = Some(BaseSync {
3834                    tip: tip.to_owned(),
3835                    behind,
3836                    attempts,
3837                    conflict: Some(why.clone()),
3838                    already_in: None,
3839                });
3840                self.state.event("land", why);
3841                self.state.save()?;
3842                return Ok(true);
3843            }
3844        }
3845        self.state.status = RunStatus::AlreadyInBase;
3846        self.state.base_sync = Some(BaseSync {
3847            tip: tip.to_owned(),
3848            behind,
3849            attempts,
3850            conflict: None,
3851            already_in: Some(evidence),
3852        });
3853        self.state.event("land", message);
3854        self.state.save()?;
3855        self.settle_questions();
3856        Ok(true)
3857    }
3858
3859    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
3860    /// last landed the winner on, once it has run, else the commit the run
3861    /// branched from.
3862    ///
3863    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
3864    /// and `vote` all happen before there is a winner to rebase, so they
3865    /// compare every candidate against the branch point on purpose, and a
3866    /// base that moves after they are already done cannot change an answer
3867    /// they already gave.
3868    fn landing_base(&self) -> String {
3869        self.state
3870            .base_sync
3871            .as_ref()
3872            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3873    }
3874
3875    // ------------------------------------------------------- operator fix
3876
3877    /// Route specific, already-recorded review findings to a fixer for a
3878    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
3879    /// entry point.
3880    ///
3881    /// Distinct from `review_loop`'s own fix step in three ways: it never
3882    /// runs a reviewer wave, it never spends review-round budget, and what
3883    /// happened is recorded as an [`OperatorFixRequest`] appended to
3884    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
3885    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
3886    /// and vote must never be rewritten to look like a manufactured blocking
3887    /// verdict.
3888    ///
3889    /// Only meaningful once review has actually concluded: `Ready` (handed
3890    /// off with findings still open, or simply concluded clean while minor
3891    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
3892    /// gate failed). Everything else is refused: a run still in progress
3893    /// should simply be resumed, and a `Merged` run's branch has already
3894    /// landed — reopening *this* run's own record cannot change that, so the
3895    /// answer there is a fresh `magi review <branch>`.
3896    ///
3897    /// A real commit here re-verifies through a fresh, ordinary review-only
3898    /// run on the same branch ([`Self::review`]) rather than reopening this
3899    /// run's own `review_loop`: once any round in this run's history went
3900    /// clean, `review_conclusion` treats that as permanent by design (the
3901    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
3902    /// way to force one more genuine reviewer wave out of *this* run without
3903    /// either rewriting history or weakening that guarantee for every other
3904    /// caller. A review-only run costs nothing extra — no implementation, no
3905    /// judging, no vote — and exercises the exact same review → verify →
3906    /// gate → (human) merge path, unmodified.
3907    pub async fn fix_selected(
3908        &mut self,
3909        ids: &[String],
3910        reason: &str,
3911        allow_stale: bool,
3912    ) -> Result<()> {
3913        let reason = reason.trim();
3914        if reason.is_empty() {
3915            bail!("a fix request needs a reason — that is the operator's own record of why");
3916        }
3917        if ids.is_empty() {
3918            bail!("no finding id given");
3919        }
3920        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3921            bail!(
3922                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3923                 has already concluded — can be given a targeted fix. A run still \
3924                 in progress should simply be resumed; a `merged` run's branch has \
3925                 already landed, so its answer is a fresh `magi review <branch>`, \
3926                 not reopening this run's own record",
3927                self.state.id,
3928                self.state.status.as_str()
3929            );
3930        }
3931        let Some(winner) = self.state.winner().cloned() else {
3932            bail!("run {} has no winning candidate to fix", self.state.id);
3933        };
3934        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3935            bail!(
3936                "branch `{}` no longer exists; this run cannot be extended",
3937                winner.branch
3938            );
3939        }
3940        let home = crate::run::home();
3941        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3942            bail!(
3943                "run {} is currently being worked on by another magi process",
3944                self.state.id
3945            );
3946        }
3947        // Held for the rest of this call, including the follow-up review
3948        // below: two `magi fix` invocations against the same run must not
3949        // both reach the worktree manipulation further down, which would
3950        // otherwise race to remove and recreate the same directory — see
3951        // [`FixClaim`]'s own doc.
3952        let _claim = FixClaim::acquire(&self.state.dir())?;
3953
3954        // Resolve every id before spending anything — an unknown id refuses
3955        // the whole request rather than silently dropping it — and dedup
3956        // while keeping the operator's own order.
3957        let mut seen = BTreeSet::new();
3958        let mut findings = Vec::new();
3959        let mut missing = Vec::new();
3960        for id in ids {
3961            if !seen.insert(id.clone()) {
3962                continue;
3963            }
3964            match self.state.finding(id) {
3965                Some((round, rec, f)) => findings.push(OperatorFixFinding {
3966                    id: f.id.clone(),
3967                    severity: f.severity,
3968                    reviewer_vote: rec.vote,
3969                    round: round.round,
3970                    round_head: round.head.clone(),
3971                    reviewer: rec.reviewer,
3972                    agent: rec.agent.clone(),
3973                    file: f.file.clone(),
3974                    line: f.line,
3975                    title: f.title.clone(),
3976                    detail: f.detail.clone(),
3977                    outcome: OperatorFixOutcome::Pending,
3978                }),
3979                None => missing.push(id.clone()),
3980            }
3981        }
3982        if !missing.is_empty() {
3983            bail!(
3984                "unknown finding id(s): {}; nothing was changed",
3985                missing.join(", ")
3986            );
3987        }
3988
3989        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3990        let stale_details: Vec<(String, String)> = findings
3991            .iter()
3992            .filter(|f| f.round_head != head_at_request)
3993            .map(|f| (f.id.clone(), f.round_head.clone()))
3994            .collect();
3995        let stale = !stale_details.is_empty();
3996        if stale && !allow_stale {
3997            bail!(
3998                "the branch has moved since some finding(s) were raised — {} — now \
3999                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4000                stale_details
4001                    .iter()
4002                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4003                    .collect::<Vec<_>>()
4004                    .join(", "),
4005                short(&head_at_request)
4006            );
4007        }
4008
4009        let request = OperatorFixRequest {
4010            requested_at: Timestamp::now(),
4011            reason: reason.to_owned(),
4012            findings,
4013            head_at_request: head_at_request.clone(),
4014            allow_stale,
4015            stale,
4016            fix: None,
4017            result_head: None,
4018            follow_up_review_run: None,
4019        };
4020        self.state.event(
4021            "fix",
4022            format!(
4023                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4024                request.findings.len(),
4025                request
4026                    .findings
4027                    .iter()
4028                    .map(|f| f.id.as_str())
4029                    .collect::<Vec<_>>()
4030                    .join(", "),
4031            ),
4032        );
4033        // Recorded now, before any worktree work or the fixer call itself —
4034        // and re-saved at each checkpoint below: a crash at any point after
4035        // this (mid fixer call, mid follow-up review) must not lose the fact
4036        // that this was requested, for which findings, and why. Everything
4037        // past this point reads and writes through `request_index` rather
4038        // than a local variable, since `request` itself is moved here.
4039        self.state.operator_fixes.push(request);
4040        self.state.save()?;
4041        let request_index = self.state.operator_fixes.len() - 1;
4042
4043        // A fresh, dedicated worktree for this one call, never the winner's
4044        // own worktree in place: that one may already be gone (folded away),
4045        // and reusing it in place would leave the branch checked out there
4046        // when the follow-up review below tries to check it out again. Freed
4047        // immediately after, either way — but only once confirmed clean:
4048        // `worktree_remove` is a `git worktree remove --force`, which would
4049        // otherwise discard uncommitted work left there by the operator or
4050        // another process before this had a chance to even look at it.
4051        if winner.worktree.exists() {
4052            // Lockfiles a rescue commit withheld stay untracked on purpose and
4053            // are already recorded; they are not the operator's work to protect.
4054            let dirty = git::git(
4055                &winner.worktree,
4056                &["status", "--porcelain", "--untracked-files=all"],
4057            )
4058            .await?;
4059            let only_withheld = dirty.lines().all(|l| {
4060                l.strip_prefix("?? ")
4061                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4062            });
4063            if !only_withheld {
4064                bail!(
4065                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4066                     discard them first",
4067                    winner.worktree.display()
4068                );
4069            }
4070            git::worktree_remove(&self.state.repo, &winner.worktree)
4071                .await
4072                .ok();
4073        }
4074        let fix_worktree = self.state.worktree_root().join("operator-fix");
4075        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4076        git::git(
4077            &self.state.repo,
4078            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4079        )
4080        .await
4081        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4082        if !git::is_clean(&fix_worktree).await? {
4083            git::worktree_remove(&self.state.repo, &fix_worktree)
4084                .await
4085                .ok();
4086            bail!(
4087                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4088                winner.branch
4089            );
4090        }
4091
4092        let run_id = self.state.id.clone();
4093        let prompts = self.state.config.prompts.clone();
4094        let language = self.state.config.graph.language.clone();
4095        let sessions = self.state.config.graph.sessions;
4096        let artifacts = agent::artifacts_dir(&self.state.dir());
4097        let (fix_spec, fix_seat_key) = match &self.roles.fixer {
4098            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4099            _ => (
4100                self.state
4101                    .config
4102                    .agent(&winner.agent)
4103                    .cloned()
4104                    .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4105                format!("impl-{}", winner.label),
4106            ),
4107        };
4108        let seat = self.seat(&fix_seat_key, &fix_spec.id);
4109        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4110            .findings
4111            .iter()
4112            .map(|f| Finding {
4113                id: f.id.clone(),
4114                severity: f.severity,
4115                file: f.file.clone(),
4116                line: f.line,
4117                title: f.title.clone(),
4118                detail: f.detail.clone(),
4119            })
4120            .collect();
4121        let job = SeatJob {
4122            prompt: prompt::operator_fix(
4123                &self.state.instruction,
4124                &finding_list,
4125                reason,
4126                &stale_details,
4127                &head_at_request,
4128                &language,
4129            ),
4130            spec: fix_spec.clone(),
4131            seat,
4132            cwd: fix_worktree.clone(),
4133            timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4134            allow_write: true,
4135            sessions,
4136            artifacts: artifacts.clone(),
4137            stem: "operator-fix".to_owned(),
4138            handover: None,
4139        };
4140        let cache = self.state.config.cache_dir();
4141        let ctx = WaveCtx {
4142            carry_seats: false,
4143            run: &run_id,
4144            node: "fix",
4145            prompts: &prompts,
4146            cache: cache.as_deref(),
4147            round: None,
4148        };
4149        let (seat, out) =
4150            run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4151        let agent_id = seat.agent.clone();
4152
4153        let mut fix = FixRecord {
4154            agent: agent_id,
4155            addressed: Vec::new(),
4156            rejected: Vec::new(),
4157            notes: String::new(),
4158            committed: false,
4159            failed: None,
4160            duration_ms: 0,
4161            continuation: None,
4162        };
4163        let mut final_seat = seat.clone();
4164        match out {
4165            AgentOutcome::Ok(o) => {
4166                fix.duration_ms = o.duration_ms;
4167                let parsed = verdict::extract_json::<FixReport>(&o.text);
4168                let incomplete_reason = match &parsed {
4169                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4170                        "the reply parsed, but it reported a command whose own CLI \
4171                         never confirmed an exit status"
4172                            .to_owned(),
4173                    ),
4174                    Ok(_) => None,
4175                    Err(e) => Some(e.to_string()),
4176                };
4177                match incomplete_reason {
4178                    None => {
4179                        let report = parsed.expect("checked Ok above");
4180                        fix.addressed = report.addressed;
4181                        fix.rejected = report.rejected;
4182                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4183                    }
4184                    Some(reason) => {
4185                        let (resumed_seat, resolved, failure, cont) = self
4186                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4187                            .await;
4188                        fix.duration_ms += cont.cumulative_wait_ms;
4189                        fix.continuation = Some(cont);
4190                        final_seat = resumed_seat;
4191                        match resolved {
4192                            Some(report) => {
4193                                fix.addressed = report.addressed;
4194                                fix.rejected = report.rejected;
4195                                fix.notes =
4196                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4197                            }
4198                            None => fix.failed = failure,
4199                        }
4200                    }
4201                }
4202            }
4203            AgentOutcome::Dropped(o) => {
4204                fix.duration_ms = o.duration_ms;
4205                let why = o
4206                    .dropped
4207                    .as_ref()
4208                    .map(|d| d.why.as_str())
4209                    .unwrap_or("the CLI ended the stream without delivering its answer");
4210                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4211            }
4212            AgentOutcome::Quota(o) => {
4213                self.state.quota.push(QuotaLoss {
4214                    seat: final_seat.key.clone(),
4215                    node: "fix".to_owned(),
4216                    at: Timestamp::now(),
4217                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4218                });
4219                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4220            }
4221            AgentOutcome::Failed(e) => fix.failed = Some(e),
4222        }
4223        if fix.continuation.is_none() {
4224            fix.continuation = Some(ContinuationRecord::not_needed());
4225        }
4226        self.state.seats.insert(final_seat.key.clone(), final_seat);
4227
4228        let rescue_message = format!(
4229            "magi: operator-selected fix ({}) (uncommitted work)",
4230            self.state.operator_fixes[request_index]
4231                .findings
4232                .iter()
4233                .map(|f| f.id.as_str())
4234                .collect::<Vec<_>>()
4235                .join(", ")
4236        );
4237        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4238            self.state.note_withheld("fix", &r.withheld);
4239        }
4240        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4241        fix.committed = after != head_at_request;
4242        git::worktree_remove(&self.state.repo, &fix_worktree)
4243            .await
4244            .ok();
4245
4246        self.state.event(
4247            "fix",
4248            match &fix.failed {
4249                Some(reason) => format!(
4250                    "operator fix: adoption report was lost ({reason}); {}",
4251                    if fix.committed {
4252                        "committed"
4253                    } else {
4254                        "NO new commit"
4255                    }
4256                ),
4257                None => format!(
4258                    "operator fix: {} addressed, {} rejected, {}",
4259                    fix.addressed.len(),
4260                    fix.rejected.len(),
4261                    if fix.committed {
4262                        "committed"
4263                    } else {
4264                        "NO new commit"
4265                    }
4266                ),
4267            },
4268        );
4269
4270        // Every selected finding gets an outcome — never left `Pending` once
4271        // the fixer's own turn is over. A report that never came back at all
4272        // marks every one of them `Unreported`, not silently "not addressed":
4273        // quota, a dropped stream, or an exhausted continuation are gaps in
4274        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4275        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4276        for f in &mut self.state.operator_fixes[request_index].findings {
4277            f.outcome = if fix.failed.is_some() {
4278                OperatorFixOutcome::Unreported
4279            } else if fix.addressed.contains(&f.id) {
4280                OperatorFixOutcome::Addressed
4281            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4282                OperatorFixOutcome::Rejected { why: r.why.clone() }
4283            } else {
4284                OperatorFixOutcome::Unreported
4285            };
4286        }
4287
4288        let committed = fix.committed;
4289        if committed {
4290            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4291        }
4292        self.state.operator_fixes[request_index].fix = Some(fix);
4293        // Saved again now that the fixer's own outcome is final, on top of
4294        // the save right after the request was first pushed above.
4295        self.state.save()?;
4296
4297        if committed {
4298            self.state.event(
4299                "fix",
4300                format!(
4301                    "operator fix committed {}; opening a follow-up review-only run",
4302                    short(&after)
4303                ),
4304            );
4305            // The operator asked for the fix, and the follow-up serves whatever
4306            // task the run it follows served.
4307            let origin =
4308                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4309            match Self::review(
4310                &self.state.repo,
4311                &winner.branch,
4312                self.state.config.clone(),
4313                origin,
4314            )
4315            .await
4316            {
4317                Ok(mut follow_up) => {
4318                    follow_up.state.event(
4319                        "start",
4320                        format!(
4321                            "requested by an operator fix on run {} for finding(s) {}",
4322                            self.state.id,
4323                            self.state.operator_fixes[request_index]
4324                                .findings
4325                                .iter()
4326                                .map(|f| f.id.as_str())
4327                                .collect::<Vec<_>>()
4328                                .join(", "),
4329                        ),
4330                    );
4331                    follow_up.state.save()?;
4332                    let follow_up_id = follow_up.state.id.clone();
4333                    // The follow-up is a run like any other: it belongs to the
4334                    // task of the run it follows, or to one filed for it.
4335                    let adopted = match crate::direct::adopt(
4336                        &follow_up.state,
4337                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4338                    ) {
4339                        Ok(a) => a,
4340                        Err(e) => {
4341                            // An ownerless run must not spend agent calls; it
4342                            // stays saved, and `magi run --resume` adopts it.
4343                            self.state.event(
4344                                "fix",
4345                                format!(
4346                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4347                                ),
4348                            );
4349                            self.state.save()?;
4350                            return Ok(());
4351                        }
4352                    };
4353                    let executed = follow_up.execute().await;
4354                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4355                    if let Some(a) = adopted {
4356                        a.finish(&follow_up.state, executed);
4357                    }
4358                    if let Some(e) = failure {
4359                        self.state.event(
4360                            "fix",
4361                            format!(
4362                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4363                            ),
4364                        );
4365                    }
4366                    self.state.operator_fixes[request_index].follow_up_review_run =
4367                        Some(follow_up_id);
4368                }
4369                Err(e) => {
4370                    self.state.event(
4371                        "fix",
4372                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4373                    );
4374                }
4375            }
4376            self.state.save()?;
4377        }
4378
4379        Ok(())
4380    }
4381
4382    // --------------------------------------------------------------- review
4383
4384    /// The agent and seat key that fix the winner's tree: the configured
4385    /// fixer, else the winner's own implementer seat, whose conversation
4386    /// continues now that the competition is over. Shared by the review loop
4387    /// and the gate-fix round so both talk to the same seat.
4388    fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4389        match &self.roles.fixer {
4390            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4391            _ => (
4392                self.state
4393                    .config
4394                    .agent(&winner.agent)
4395                    .cloned()
4396                    .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4397                format!("impl-{}", winner.label),
4398            ),
4399        }
4400    }
4401
4402    async fn review_loop(&mut self) -> Result<()> {
4403        // A base that would not rebase is a person's decision, not a review
4404        // round: nothing here would change the answer, and reviewers and a
4405        // fixer would be spending real budget on a tree that cannot land
4406        // regardless of what they find.
4407        if self
4408            .state
4409            .base_sync
4410            .as_ref()
4411            .is_some_and(|s| s.conflict.is_some())
4412        {
4413            return Ok(());
4414        }
4415        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4416        // agent files with `magi task add` name the run that paid for it. The
4417        // prompt overlay is cloned alongside it because the waves borrow it
4418        // while `self` is mutably borrowed by the node's own bookkeeping.
4419        let run_id = self.state.id.clone();
4420        let prompts = self.state.config.prompts.clone();
4421        let Some(winner) = self.state.winner().cloned() else {
4422            return Ok(());
4423        };
4424        let max_rounds = self.state.config.graph.review_rounds;
4425        // A clean round, an exhausted round budget, or a stalled tree (see
4426        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4427        // they are recorded — recomputed here, not read off `status`, so a
4428        // reentry into a run that already stopped restates the identical
4429        // verdict instead of silently handing back whatever an earlier node
4430        // in this same walk clobbered `status` to (a solo-candidate
4431        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4432        // below runs an empty range once the budget is spent, and would
4433        // otherwise fall through without touching `status` at all.
4434        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4435            // A reentry after a crash between the last round's save and
4436            // `stop_reviewing` reaches the hand-off here, not there.
4437            if status == RunStatus::Gating {
4438                self.record_contested_handoff();
4439            }
4440            self.state.status = status;
4441            self.state.save()?;
4442            return Ok(());
4443        }
4444        self.state.status = RunStatus::Reviewing;
4445        // A last recorded round whose own verification never resolved
4446        // (`ResourceBlocked` — the shared build cache, not the patch) is
4447        // never a concluded round, whatever the round budget says: starting
4448        // a fresh round on top of it would spend a whole new reviewer wave
4449        // re-reading an unchanged patch instead of just retrying the one
4450        // check that actually needs it, and once the budget is spent the
4451        // loop below has nothing left to do at all (its range is empty).
4452        // Retry that check directly instead, exactly the same retry
4453        // `stop_reviewing` already does for its own catch-up case.
4454        if self
4455            .state
4456            .reviews
4457            .last()
4458            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4459        {
4460            let shell = self.state.config.shell();
4461            return self
4462                .stop_reviewing(
4463                    "the last round's own verification never resolved",
4464                    &shell,
4465                    &winner.worktree,
4466                )
4467                .await;
4468        }
4469
4470        let repo = self.state.repo.clone();
4471        let root = self.state.worktree_root();
4472        let language = self.state.config.graph.language.clone();
4473        let sessions = self.state.config.graph.sessions;
4474        let artifacts = agent::artifacts_dir(&self.state.dir());
4475        let base = self.landing_base();
4476        let base_short = short(&base);
4477        let reviewers = self.roles.reviewers.clone();
4478        let shell = self.state.config.shell();
4479
4480        for round in (self.state.reviews.len() + 1)..=max_rounds {
4481            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4482            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4483            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4484            // The prior round's own record, already persisted — never a
4485            // hand-carried variable of just its failing output: that is
4486            // exactly what let a round's e2e result drift out of sync with
4487            // which commit it was actually about (see `SCHEMA`'s doc for
4488            // schema 8). Judged against `head`, the commit reviewers are
4489            // about to look at now, so the summary always reads as "an
4490            // earlier head" here — this round's own patch has not been
4491            // checked yet.
4492            let prev_verification = self
4493                .state
4494                .reviews
4495                .last()
4496                .and_then(|r| r.verification_summary(&head));
4497
4498            // Each reviewer gets its own detached checkout of exactly this
4499            // commit: nobody can perturb the winner's tree, and the fixer can
4500            // keep working without racing a reviewer.
4501            let mut jobs = Vec::new();
4502            for (r, spec) in reviewers.iter().cloned().enumerate() {
4503                let wt = root.join(format!("review-{}", r + 1));
4504                if wt.exists() {
4505                    git::reset_detached(&wt, &head).await?;
4506                } else {
4507                    git::worktree_add_detached(&repo, &wt, &head).await?;
4508                }
4509                let seat_key = format!("review-{}", r + 1);
4510                // The seat starts the round on whoever answered it last, not
4511                // on the agent the spec names, so a failure is not re-paid.
4512                let spec = pick_start_spec(
4513                    &self.roles.reviewer_roster,
4514                    spec,
4515                    self.state.seat_history.get(&seat_key),
4516                );
4517                let seat = self.seat(&seat_key, &spec.id);
4518                jobs.push(SeatJob {
4519                    prompt: prompt::review(&prompt::ReviewCtx {
4520                        instruction: &self.state.instruction,
4521                        branch: &winner.branch,
4522                        base_short: &base_short,
4523                        stat: &stat,
4524                        patch: &patch,
4525                        verification: prev_verification.as_ref(),
4526                        reviewers: reviewers.len(),
4527                        round,
4528                        rounds: max_rounds,
4529                        // A review-only run has no rankings, so nothing
4530                        // competed for this patch and the reviewer is told so.
4531                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4532                        lens: Lens::for_seat(r),
4533                        language: &language,
4534                    }),
4535                    spec,
4536                    seat,
4537                    cwd: wt,
4538                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4539                    allow_write: false,
4540                    sessions,
4541                    artifacts: artifacts.clone(),
4542                    stem: format!("review-{round}-{}", r + 1),
4543                    handover: None,
4544                });
4545            }
4546
4547            self.state.event(
4548                "review",
4549                format!(
4550                    "round {round}: {} reviewers on {}",
4551                    jobs.len(),
4552                    short(&head)
4553                ),
4554            );
4555            let mut quota_losses = Vec::new();
4556            let review_retries = self.state.config.graph.retries;
4557            let review_cache = self.state.config.cache_dir();
4558            let ctx = WaveCtx {
4559                carry_seats: true,
4560                run: &run_id,
4561                node: "review",
4562                prompts: &prompts,
4563                cache: review_cache.as_deref(),
4564                round: Some(round),
4565            };
4566            let results = ask_json_wave::<Review>(
4567                jobs,
4568                Arc::clone(&self.sem),
4569                review_retries,
4570                &self.roles.reviewer_roster,
4571                &ctx,
4572                &mut quota_losses,
4573                &mut self.state,
4574                &|_: &Review| Ok(()),
4575            )
4576            .await;
4577            // Counted before the move below: how many of *this* round's
4578            // reviewer seats were lost to their own rate limit, as opposed to
4579            // a crash, a timeout, or unparsable output — see `round_is_clean`.
4580            let round_quota_missing = quota_losses.len();
4581            self.state.quota.extend(quota_losses);
4582
4583            let mut records = Vec::new();
4584            let mut all_findings = Vec::new();
4585            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4586                let agent_id = seat.agent.clone();
4587                self.state.seats.insert(seat.key.clone(), seat);
4588                let mut record = ReviewRecord {
4589                    reviewer: r + 1,
4590                    agent: agent_id,
4591                    summary: String::new(),
4592                    findings: Vec::new(),
4593                    vote: None,
4594                    failed: None,
4595                    duration_ms: 0,
4596                    // Set for both outcomes: `failed: Some(_)` with
4597                    // `attempts > 0` is a seat every retry still lost, not a
4598                    // recovered one — only `failed: None` with `attempts > 0`
4599                    // reads as "answered after a nudge" (see this field's own
4600                    // doc).
4601                    attempts,
4602                };
4603                match res {
4604                    Ok((review, out)) => {
4605                        // Sanitized here, at the point every other piece of
4606                        // agent prose in this file is (candidate summaries,
4607                        // deliberation turns, vote reasons): a reviewer's own
4608                        // words are the one thing about it that could name
4609                        // it, and reconsideration below broadcasts this same
4610                        // summary and these same findings to every other
4611                        // seat on the panel.
4612                        record.summary =
4613                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
4614                        record.vote = Some(review.vote);
4615                        record.duration_ms = out.duration_ms;
4616                        for (n, mut f) in review.findings.into_iter().enumerate() {
4617                            // ids are magi's, never the agent's: the fixer's
4618                            // adoption report is keyed by them.
4619                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4620                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4621                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4622                            // `file` is agent-supplied prose too, never
4623                            // checked against the real tree — the same
4624                            // exposure `title`/`detail` above have, just in
4625                            // a field easy to forget because it looks like a
4626                            // path rather than free text.
4627                            f.file = f
4628                                .file
4629                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4630                            all_findings.push(f.clone());
4631                            record.findings.push(f);
4632                        }
4633                        self.state.event(
4634                            "review",
4635                            format!(
4636                                "round {round}: reviewer {} voted {} with {} finding(s)",
4637                                r + 1,
4638                                review.vote.label(),
4639                                record.findings.len()
4640                            ),
4641                        );
4642                    }
4643                    Err(e) => {
4644                        record.failed = Some(e.to_string());
4645                        self.state.event(
4646                            "review",
4647                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4648                        );
4649                    }
4650                }
4651                records.push(record);
4652            }
4653
4654            // Tally the round's votes and, if they split, spend the one
4655            // round of reconsideration the split -> deliberate -> revote
4656            // shape `judge`/`vote` use for the panel, sized down to what a
4657            // read-only review round can afford: one round, and a revote
4658            // rather than an argument, because the panel already wrote its
4659            // reasoning down as findings the first time around.
4660            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4661            let vote_split =
4662                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4663            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4664            if vote_split {
4665                self.state.event(
4666                    "review",
4667                    format!(
4668                        "round {round}: votes split ({}) — one round of reconsideration",
4669                        initial_votes
4670                            .iter()
4671                            .map(|v| v.label())
4672                            .collect::<Vec<_>>()
4673                            .join(", ")
4674                    ),
4675                );
4676                // Seats read every seat's findings and votes, still numbered
4677                // and never named — the same anonymity `review` itself keeps.
4678                let panel: Vec<ReviewSeatReport<'_>> = records
4679                    .iter()
4680                    .filter_map(|r| {
4681                        r.vote.map(|vote| ReviewSeatReport {
4682                            reviewer: r.reviewer,
4683                            vote,
4684                            summary: &r.summary,
4685                            findings: &r.findings,
4686                        })
4687                    })
4688                    .collect();
4689
4690                let mut jobs = Vec::new();
4691                let mut seats_at = Vec::new();
4692                for (r, spec) in reviewers.iter().cloned().enumerate() {
4693                    // A seat with no initial vote has nothing to reconsider
4694                    // from and stays absent, the same as it stayed absent
4695                    // from `panel` above.
4696                    if records[r].vote.is_none() {
4697                        continue;
4698                    }
4699                    let wt = root.join(format!("review-{}", r + 1));
4700                    let seat_key = format!("review-{}", r + 1);
4701                    let spec = self.occupant(&seat_key, spec);
4702                    let seat = self.seat(&seat_key, &spec.id);
4703                    // A seat with no live session has already forgotten the
4704                    // initial review's prompt — restate the patch it is
4705                    // voting on, the same as `deliberate`/`vote` do for a
4706                    // judge in the same position.
4707                    // The panel already carries this seat's own review and
4708                    // vote, so restating the patch makes the prompt whole for
4709                    // a seat handed to another agent.
4710                    let build = |with_patch: bool| {
4711                        prompt::review_reconsider(&ReviewReconsiderCtx {
4712                            instruction: &self.state.instruction,
4713                            reviewer: r + 1,
4714                            lens: Lens::for_seat(r),
4715                            panel: &panel,
4716                            patch: with_patch.then_some(ReviewPatch {
4717                                branch: &winner.branch,
4718                                base_short: &base_short,
4719                                stat: &stat,
4720                                patch: &patch,
4721                            }),
4722                            round,
4723                            rounds: max_rounds,
4724                            language: &language,
4725                        })
4726                    };
4727                    let full = build(true);
4728                    let prompt = if has_context(&spec, &seat, sessions) {
4729                        build(false)
4730                    } else {
4731                        full.clone()
4732                    };
4733                    jobs.push(SeatJob {
4734                        prompt,
4735                        spec,
4736                        seat,
4737                        cwd: wt,
4738                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4739                        allow_write: false,
4740                        sessions,
4741                        artifacts: artifacts.clone(),
4742                        stem: format!("review-{round}-reconsider-{}", r + 1),
4743                        handover: Some(full),
4744                    });
4745                    seats_at.push(r);
4746                }
4747
4748                let mut recon_quota_losses = Vec::new();
4749                let recon_cache = self.state.config.cache_dir();
4750                let recon_ctx = WaveCtx {
4751                    carry_seats: true,
4752                    run: &run_id,
4753                    node: "review",
4754                    prompts: &prompts,
4755                    cache: recon_cache.as_deref(),
4756                    round: Some(round),
4757                };
4758                let recon_results = ask_json_wave::<ReviewRevote>(
4759                    jobs,
4760                    Arc::clone(&self.sem),
4761                    review_retries,
4762                    &self.roles.reviewer_roster,
4763                    &recon_ctx,
4764                    &mut recon_quota_losses,
4765                    &mut self.state,
4766                    &|_: &ReviewRevote| Ok(()),
4767                )
4768                .await;
4769                self.state.quota.extend(recon_quota_losses);
4770
4771                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4772                    let agent_id = seat.agent.clone();
4773                    self.state.seats.insert(seat.key.clone(), seat);
4774                    let mut rec = ReviewRevoteRecord {
4775                        reviewer: r + 1,
4776                        agent: agent_id,
4777                        vote: None,
4778                        reason: String::new(),
4779                        failed: None,
4780                    };
4781                    match res {
4782                        Ok((rv, _)) => {
4783                            rec.vote = Some(rv.vote);
4784                            rec.reason =
4785                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4786                            self.state.event(
4787                                "review",
4788                                format!(
4789                                    "round {round}: reviewer {} revoted {}",
4790                                    r + 1,
4791                                    rv.vote.label()
4792                                ),
4793                            );
4794                        }
4795                        Err(e) => {
4796                            rec.failed = Some(e.to_string());
4797                            self.state.event(
4798                                "review",
4799                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4800                            );
4801                        }
4802                    }
4803                    reconsideration.push(rec);
4804                }
4805            } else if initial_votes.len() > 1 {
4806                self.state.event(
4807                    "review",
4808                    format!(
4809                        "round {round}: votes agreed ({}) — no reconsideration",
4810                        initial_votes[0].label()
4811                    ),
4812                );
4813            }
4814
4815            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4816            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4817            // A round that already has a blocking finding and a round left to
4818            // try is going back to the fixer no matter what `verify.e2e`
4819            // says, so running it first only spends the loop's slowest step
4820            // (minutes, for a Rust repo's full test suite) on a head about
4821            // to be rewritten. Deferred, never skipped: `verify.e2e` still
4822            // runs once a round has no blocking findings left (see
4823            // `round_is_clean`, which a deferred — empty — `e2e` can never
4824            // satisfy since `blocking` is nonzero whenever this branch is
4825            // taken), and `stop_reviewing` forces a real run before it will
4826            // ever read a deferred round as green.
4827            let defer_e2e =
4828                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4829            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4830                let reason =
4831                    format!("{blocking} blocking finding(s) already required a fix this round");
4832                self.state.event(
4833                    "verify",
4834                    format!(
4835                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4836                         {}); it will run once a round has none left",
4837                        short(&head)
4838                    ),
4839                );
4840                (Vec::new(), false, true, Some(reason))
4841            } else {
4842                let e2e_commands = self.state.config.verify.e2e.clone();
4843                let cache_dir = self.state.config.cache_dir();
4844                let context = format!("round {round}");
4845                let (e2e, verify_retried) = with_cache_lease(
4846                    &mut self.state,
4847                    cache_dir.as_deref(),
4848                    "e2e",
4849                    "e2e",
4850                    &winner.worktree,
4851                    &head,
4852                    verify_timeout,
4853                    &context,
4854                    |state, budget| {
4855                        let shell = shell.clone();
4856                        let e2e_commands = e2e_commands.clone();
4857                        let worktree = winner.worktree.clone();
4858                        let context = context.clone();
4859                        async move {
4860                            run_e2e_with_retry(
4861                                state,
4862                                &shell,
4863                                &e2e_commands,
4864                                &worktree,
4865                                budget,
4866                                &context,
4867                            )
4868                            .await
4869                        }
4870                    },
4871                )
4872                .await;
4873                (e2e, verify_retried, false, None)
4874            };
4875
4876            let expected = records.len();
4877            let answered = records.iter().filter(|r| r.failed.is_none()).count();
4878            let incomplete = answered < expected;
4879            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4880            let policy = self.state.config.graph.incomplete_review;
4881            let clean = round_is_clean(
4882                blocking,
4883                e2e_ok,
4884                answered,
4885                expected,
4886                round_quota_missing,
4887                policy,
4888            );
4889
4890            let mut round_record = ReviewRound {
4891                round,
4892                head: head.clone(),
4893                verified_head: None,
4894                verified_at: None,
4895                reviews: records,
4896                e2e,
4897                verify_retried,
4898                e2e_deferred,
4899                e2e_defer_reason,
4900                fix: None,
4901                blocking,
4902                answered,
4903                expected,
4904                clean,
4905                progressed: false,
4906                vote_split,
4907                reconsideration,
4908                verdict: None,
4909            };
4910            // The final vote per seat is its revote where reconsideration
4911            // ran and answered, its initial vote otherwise — the same
4912            // fallback `tally` uses for a judge whose private vote failed.
4913            round_record.verdict = ReviewVote::worst(
4914                round_record
4915                    .final_votes()
4916                    .into_iter()
4917                    .map(|(_, _, vote)| vote),
4918            );
4919            // Which commit and when magi actually attempted to check —
4920            // known the moment a command was dispatched against `head`,
4921            // whether or not it finished: a resource-blocked attempt still
4922            // targeted a specific commit at a specific time, and leaving
4923            // that unrecorded is exactly what made `verification_summary`
4924            // report a fresh attempt as "commit unknown ... recorded before
4925            // this was tracked", indistinguishable from a genuinely old,
4926            // untracked record. Only a deferred or unconfigured round never
4927            // ran at all and has nothing to record — see
4928            // `ReviewRound::verified_head`'s own doc.
4929            if !matches!(
4930                round_record.e2e_status(),
4931                E2eStatus::Deferred | E2eStatus::NotConfigured
4932            ) {
4933                round_record.verified_head = Some(head.clone());
4934                round_record.verified_at = Some(Timestamp::now());
4935            }
4936            let this_round_verification = round_record.verification_summary(&head);
4937
4938            if incomplete {
4939                let missing: Vec<String> = round_record
4940                    .reviews
4941                    .iter()
4942                    .filter(|r| r.failed.is_some())
4943                    .map(|r| format!("review-{}", r.reviewer))
4944                    .collect();
4945                self.state.event(
4946                    "review",
4947                    format!(
4948                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4949                        missing.join(", ")
4950                    ),
4951                );
4952            }
4953
4954            if clean {
4955                self.state.event(
4956                    "review",
4957                    if incomplete && policy == IncompleteReviewPolicy::Warn {
4958                        format!(
4959                            "round {round}: clean (warn policy, incomplete panel) — no \
4960                             blocking findings from the seats that answered, verification green"
4961                        )
4962                    } else if incomplete {
4963                        format!(
4964                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4965                             quorum) — no blocking findings from the seats that answered, \
4966                             verification green",
4967                            expected - answered
4968                        )
4969                    } else {
4970                        format!("round {round}: clean — no blocking findings, verification green")
4971                    },
4972                );
4973                self.state.reviews.push(round_record);
4974                self.state.status = RunStatus::Gating;
4975                self.state.save()?;
4976                return Ok(());
4977            }
4978
4979            // Nothing was raised and verification passed, but not every seat
4980            // answered and `round_is_clean` still refused to call it clean —
4981            // either a seat is missing for a reason other than its own quota
4982            // (a crash, a timeout, unparsable output — worth another try), or
4983            // every seat that could have answered lost its quota and nobody
4984            // is left to decide on: re-review rather than send the fixer
4985            // after a round with nothing to fix.
4986            if incomplete && blocking == 0 && e2e_ok {
4987                self.state.reviews.push(round_record);
4988                self.state.save()?;
4989                if round == max_rounds {
4990                    self.state.status = RunStatus::Blocked;
4991                    self.state.event(
4992                        "review",
4993                        format!(
4994                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4995                             refusing to call it clean",
4996                            expected - answered
4997                        ),
4998                    );
4999                    return Ok(());
5000                }
5001                continue;
5002            }
5003
5004            // Nothing for the fixer to act on (`blocking == 0`) and the only
5005            // reason this round is not clean is that magi itself never got
5006            // a command to run — the shared build cache, not the patch (see
5007            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5008            // the fixer would invite a change to appease contention that has
5009            // nothing to do with the diff, and would leave this attempt
5010            // sitting in the next round's prompt as if it were about an
5011            // earlier, superseded commit rather than what it actually is:
5012            // the same head, still waiting to be checked. Wait for it the
5013            // same way the final round's own contention is already handled,
5014            // whatever round this happens to be.
5015            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5016                self.state.reviews.push(round_record);
5017                return self
5018                    .stop_reviewing(
5019                        "the round's own verification could not run",
5020                        &shell,
5021                        &winner.worktree,
5022                    )
5023                    .await;
5024            }
5025
5026            if round == max_rounds {
5027                self.state.reviews.push(round_record);
5028                return self
5029                    .stop_reviewing(
5030                        &format!(
5031                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5032                        ),
5033                        &shell,
5034                        &winner.worktree,
5035                    )
5036                    .await;
5037            }
5038
5039            // Fix. The winner's own implementer seat continues its conversation:
5040            // the competition is over, so context is pure benefit now.
5041            let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
5042            let seat = self.seat(&fix_seat_key, &fix_spec.id);
5043            let blocking_findings: Vec<_> = all_findings
5044                .iter()
5045                .filter(|f| f.severity.blocks())
5046                .cloned()
5047                .collect();
5048            let job = SeatJob {
5049                prompt: prompt::fix(
5050                    &self.state.instruction,
5051                    &blocking_findings,
5052                    this_round_verification.as_ref(),
5053                    round,
5054                    max_rounds,
5055                    &language,
5056                ),
5057                spec: fix_spec.clone(),
5058                seat,
5059                cwd: winner.worktree.clone(),
5060                timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5061                allow_write: true,
5062                sessions,
5063                artifacts: artifacts.clone(),
5064                stem: format!("fix-{round}"),
5065                handover: None,
5066            };
5067            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5068            let cache = self.state.config.cache_dir();
5069            let ctx = WaveCtx {
5070                carry_seats: false,
5071                run: &run_id,
5072                node: "fix",
5073                prompts: &prompts,
5074                cache: cache.as_deref(),
5075                round: Some(round),
5076            };
5077            let (seat, out) =
5078                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5079            let agent_id = seat.agent.clone();
5080
5081            let mut fix = FixRecord {
5082                agent: agent_id,
5083                addressed: Vec::new(),
5084                rejected: Vec::new(),
5085                notes: String::new(),
5086                committed: false,
5087                failed: None,
5088                duration_ms: 0,
5089                continuation: None,
5090            };
5091            let mut continuation = ContinuationRecord::not_needed();
5092            let mut final_seat = seat.clone();
5093            match out {
5094                AgentOutcome::Ok(o) => {
5095                    fix.duration_ms = o.duration_ms;
5096                    let parsed = verdict::extract_json::<FixReport>(&o.text);
5097                    // A parsed report standing next to a command this same
5098                    // reply's own CLI never confirmed the exit status of is
5099                    // not a resolved answer — the identical `CommandEvidence`
5100                    // `state.jobs` renders, read here instead of only on
5101                    // display, per the completion judgment and the shown
5102                    // record needing to agree.
5103                    let incomplete_reason = match &parsed {
5104                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5105                            "the reply parsed, but it reported a command whose own CLI never \
5106                             confirmed an exit status"
5107                                .to_owned(),
5108                        ),
5109                        Ok(_) => None,
5110                        Err(e) => Some(e.to_string()),
5111                    };
5112                    match incomplete_reason {
5113                        None => {
5114                            let report = parsed.expect("checked Ok above");
5115                            fix.addressed = report.addressed;
5116                            fix.rejected = report.rejected;
5117                            fix.notes =
5118                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
5119                        }
5120                        Some(reason) => {
5121                            let (resumed_seat, resolved, failure, cont) = self
5122                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5123                                .await;
5124                            fix.duration_ms += cont.cumulative_wait_ms;
5125                            continuation = cont;
5126                            final_seat = resumed_seat;
5127                            match resolved {
5128                                Some(report) => {
5129                                    fix.addressed = report.addressed;
5130                                    fix.rejected = report.rejected;
5131                                    fix.notes = blind::sanitize_prose(
5132                                        &report.notes,
5133                                        &self.state.config.blind,
5134                                    );
5135                                }
5136                                None => fix.failed = failure,
5137                            }
5138                        }
5139                    }
5140                }
5141                // The CLI's raw error JSON is not a fix report to parse.
5142                AgentOutcome::Dropped(o) => {
5143                    fix.duration_ms = o.duration_ms;
5144                    let why = o
5145                        .dropped
5146                        .as_ref()
5147                        .map(|d| d.why.as_str())
5148                        .unwrap_or("the CLI ended the stream without delivering its answer");
5149                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5150                }
5151                AgentOutcome::Quota(o) => {
5152                    self.state.quota.push(QuotaLoss {
5153                        seat: final_seat.key.clone(),
5154                        node: "fix".to_owned(),
5155                        at: Timestamp::now(),
5156                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5157                    });
5158                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5159                }
5160                AgentOutcome::Failed(e) => fix.failed = Some(e),
5161            }
5162            fix.continuation = Some(continuation);
5163            self.state.seats.insert(final_seat.key.clone(), final_seat);
5164            if let Ok(r) = git::rescue_commit(
5165                &winner.worktree,
5166                &format!("magi: review round {round} fixes (uncommitted work)"),
5167            )
5168            .await
5169            {
5170                self.state.note_withheld("fix", &r.withheld);
5171            }
5172            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5173            fix.committed = after != before;
5174            // Judged by what `git` says moved against base, never by the
5175            // fixer's own `addressed`/`rejected` count — see
5176            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5177            // `patch` snapshot above: swallowing this error would default
5178            // `diff_after` to empty, which almost always differs from a
5179            // non-empty `patch` and reads as "progressed" — exactly backwards
5180            // for a `git` failure the stagnation check cannot see through.
5181            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5182            let progressed = diff_after != patch;
5183            let commit_note = if fix.committed {
5184                "committed"
5185            } else {
5186                "NO new commit"
5187            };
5188            let tree_note = if progressed {
5189                "changed vs base"
5190            } else {
5191                "unchanged vs base"
5192            };
5193            self.state.event(
5194                "fix",
5195                match &fix.failed {
5196                    // Distinct on purpose from "0 addressed, 0 rejected": the
5197                    // fixer's own diff still landed (blocking counts do keep
5198                    // falling round over round), only its adoption report did
5199                    // not come back, so this must never read like every
5200                    // finding was reviewed and declined.
5201                    Some(reason) => {
5202                        format!(
5203                            "round {round}: fixer's adoption report was lost ({reason}); \
5204                             {commit_note}, tree {tree_note}"
5205                        )
5206                    }
5207                    None => format!(
5208                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5209                         {tree_note}{}",
5210                        fix.addressed.len(),
5211                        fix.rejected.len(),
5212                        if continuation.outcome == ContinuationOutcome::Resumed {
5213                            format!(
5214                                " (adoption report recovered after {} continuation(s))",
5215                                continuation.attempts
5216                            )
5217                        } else {
5218                            String::new()
5219                        },
5220                    ),
5221                },
5222            );
5223            round_record.fix = Some(fix);
5224            round_record.progressed = progressed;
5225            self.state.reviews.push(round_record);
5226            self.state.save()?;
5227
5228            // The fixer's own report never came back this round, even after
5229            // `continue_fix_report`'s own budget was spent on it — not an
5230            // ordinary "no report" (dropped stream, quota, plain failure),
5231            // which already reads that way and is left to the existing round
5232            // budget. Stopping here, rather than opening another round, is
5233            // what keeps a next reviewer/fixer wave from ever being
5234            // dispatched onto `winner.worktree` while whatever the seat's
5235            // last call may still have running there is unaccounted for: no
5236            // process liveness check exists (and none is being added — see
5237            // AGENTS.md/this task's own scope), so the only way to honour
5238            // "nothing starts before a valid report returns" is to not start
5239            // anything further on this worktree from this run at all.
5240            if matches!(
5241                continuation.outcome,
5242                ContinuationOutcome::Exhausted
5243                    | ContinuationOutcome::QuotaLost
5244                    | ContinuationOutcome::NoSession
5245            ) {
5246                return self
5247                    .stop_reviewing(
5248                        "the fixer's adoption report never came back, even after resuming its \
5249                         own seat; refusing to start another round against the same worktree \
5250                         while that is unresolved",
5251                        &shell,
5252                        &winner.worktree,
5253                    )
5254                    .await;
5255            }
5256
5257            let streak = self
5258                .state
5259                .reviews
5260                .iter()
5261                .rev()
5262                .take_while(|r| !r.progressed)
5263                .count();
5264            if streak >= STAGNANT_LIMIT {
5265                return self
5266                    .stop_reviewing(
5267                        &format!(
5268                            "the tree has not moved against base for {streak} round(s) in a row"
5269                        ),
5270                        &shell,
5271                        &winner.worktree,
5272                    )
5273                    .await;
5274            }
5275        }
5276        Ok(())
5277    }
5278
5279    /// Decide, from the last recorded round's own verification, whether
5280    /// stopping the review loop is a hand-off or a genuine block.
5281    ///
5282    /// Called once the loop has given up trying — the round budget is spent,
5283    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5284    /// findings still open, never while a round is still clean or the
5285    /// incomplete-panel case handled inline above. Gate and e2e are facts
5286    /// about the tree; a lingering review finding is an opinion, and this
5287    /// workload's own `magi stats` puts reviewer precision low enough
5288    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5289    /// must not by itself stand between a green, verified change and the
5290    /// human who decides what to do with it. A red e2e is not an opinion, so
5291    /// that case still blocks, with the failing command and a tail of its
5292    /// output recorded here rather than left in `run.json` for someone to go
5293    /// find.
5294    ///
5295    /// A round that deferred its own e2e (see [`Config::graph`]'s
5296    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5297    /// only because nothing ran, and treating an empty list as a passing one
5298    /// here is exactly the "deferred painted green" bug this function exists
5299    /// to not have. When the last round's own verification never resolved —
5300    /// deferred on purpose, or a real attempt the shared build cache blocked
5301    /// — this makes (or retries) the real run, on the actual worktree this
5302    /// loop is about to stop touching, before deciding anything. A
5303    /// resource-blocked attempt is likewise never read as either green or
5304    /// red: it is evidence about the machine, not the patch (see
5305    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5306    /// blocked cache leaves this call without deciding rather than guessing
5307    /// — the caller retries on a later reentry.
5308    /// Record, once, that the review loop handed off over a blocking finding
5309    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5310    /// `land` asks the owner even with `land_approval` off. Called from every
5311    /// path that concludes `Gating`; a reentry keeps the first record.
5312    fn record_contested_handoff(&mut self) {
5313        if self.state.contested_handoff.is_some() {
5314            return;
5315        }
5316        let Some(contested) = self
5317            .state
5318            .reviews
5319            .last()
5320            .and_then(ReviewRound::contested_handoff)
5321        else {
5322            return;
5323        };
5324        self.state.event(
5325            "review",
5326            format!(
5327                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5328                 wait for the owner's approval",
5329                contested.findings.len(),
5330                contested.rejecters.len()
5331            ),
5332        );
5333        self.state.contested_handoff = Some(contested);
5334    }
5335
5336    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5337        let round_idx = self.state.reviews.len() - 1;
5338        // A deferred round and a resource-blocked one are the same shape
5339        // here: neither has a real result yet, and both get one more
5340        // attempt. Read off `e2e_status` — the single source for this —
5341        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5342        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5343        // still retries instead of being read as a settled result the
5344        // instant it stops being empty.
5345        let needs_catchup_run = matches!(
5346            self.state.reviews[round_idx].e2e_status(),
5347            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5348        );
5349        if needs_catchup_run {
5350            let round = self.state.reviews[round_idx].round;
5351            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5352            let commands = self.state.config.verify.e2e.clone();
5353            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5354            let cache_dir = self.state.config.cache_dir();
5355            let context = format!(
5356                "round {round}: verification unresolved, catching up before the final decision"
5357            );
5358            let (outcomes, verify_retried) = with_cache_lease(
5359                &mut self.state,
5360                cache_dir.as_deref(),
5361                "e2e",
5362                "e2e",
5363                worktree,
5364                &attempted_head,
5365                timeout,
5366                &context,
5367                |state, budget| {
5368                    let shell = shell.to_vec();
5369                    let commands = commands.clone();
5370                    let context = context.clone();
5371                    async move {
5372                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5373                            .await
5374                    }
5375                },
5376            )
5377            .await;
5378            let last = &mut self.state.reviews[round_idx];
5379            last.e2e = outcomes;
5380            last.verify_retried = verify_retried;
5381            // Always the commit and time this attempt actually targeted,
5382            // whether or not it happens to equal the reviewed `head` and
5383            // whether or not a command finished — see
5384            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5385            // attempt is recorded too, so a later reader sees "attempted
5386            // again at T2" rather than silence.
5387            last.verified_head = Some(attempted_head);
5388            last.verified_at = Some(Timestamp::now());
5389            if verify_inconclusive(&last.e2e) {
5390                // Still not a real result: `e2e_deferred` is left exactly
5391                // as it was, so `needs_catchup_run` above reads
5392                // `ResourceBlocked` (via `e2e_status`, which checks
5393                // `resource_blocked` before `e2e_deferred`) and retries
5394                // again on the next reentry, rather than recording
5395                // contention as a red e2e and blocking the run on it.
5396                self.state.save()?;
5397                return Ok(());
5398            }
5399            last.e2e_deferred = false;
5400        }
5401        let last = &self.state.reviews[round_idx];
5402        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5403
5404        match last.e2e_status() {
5405            E2eStatus::Failed => {
5406                let red: Vec<String> = last
5407                    .e2e
5408                    .iter()
5409                    .filter(|o| !o.ok())
5410                    .map(|o| {
5411                        format!(
5412                            "`{}` -> {:?}\n{}",
5413                            o.command,
5414                            o.code,
5415                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5416                        )
5417                    })
5418                    .collect();
5419                self.state
5420                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5421                self.state.status = RunStatus::Blocked;
5422            }
5423            // `needs_catchup_run` above already retried once this call; if
5424            // it is still blocked, this is magi's own admission it could
5425            // not get a command to run, never a verdict on the patch — the
5426            // run is left exactly where a later reentry can retry again.
5427            E2eStatus::ResourceBlocked => {
5428                self.state.event(
5429                    "review",
5430                    format!(
5431                        "{why}; e2e could not run (shared build cache unavailable); not \
5432                         deciding yet"
5433                    ),
5434                );
5435            }
5436            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5437                self.state.event(
5438                    "review",
5439                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5440                );
5441                self.record_contested_handoff();
5442                self.state.status = RunStatus::Gating;
5443            }
5444        }
5445        self.state.save()?;
5446        Ok(())
5447    }
5448
5449    // ----------------------------------------------------------------- gate
5450
5451    async fn gate(&mut self) -> Result<()> {
5452        // Judged by the review record itself, not by `status`: a solo
5453        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5454        // reentry (see `judge`), and trusting it here is exactly how a run
5455        // that exhausted its review budget got gated and merged a second
5456        // time around. `review_conclusion` recomputes the review loop's own
5457        // verdict from the round records themselves — `Gating` for a clean
5458        // round or a hand-off (see `stop_reviewing`), anything else means the
5459        // loop is still going or genuinely blocked.
5460        // A base the winner could not be replayed onto is a decision, not a
5461        // round: there is no landing tree to gate. Read as its own record for
5462        // the same reason the review verdict is.
5463        if self.state.status == RunStatus::Failed
5464            || self
5465                .state
5466                .base_sync
5467                .as_ref()
5468                .is_some_and(|s| s.conflict.is_some())
5469            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5470                != Some(RunStatus::Gating)
5471        {
5472            return Ok(());
5473        }
5474        if self.state.gate_ran {
5475            // `review_loop` derives its conclusion from the clean review
5476            // record on every reentry and therefore puts a completed run back
5477            // in `Gating`. A recorded gate is a stronger, terminal fact:
5478            // retain its original command output (or lack of any, for a repo
5479            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5480            // doc) and restore `Blocked` on a real failure rather than
5481            // pretending the command is still running or running it a second
5482            // time. `gate_ran == false` remains the only shape — unattempted,
5483            // or a resource-blocked retry — that may still need to execute a
5484            // command.
5485            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5486                self.state.status = RunStatus::Blocked;
5487                self.state.save()?;
5488            }
5489            return Ok(());
5490        }
5491        let Some(winner) = self.state.winner().cloned() else {
5492            return Ok(());
5493        };
5494        self.state.status = RunStatus::Gating;
5495        let mut outcomes = self.run_gate(&winner).await?;
5496        loop {
5497            // A resource-blocked outcome means the gate command never actually
5498            // ran - the shared build cache could not be acquired or confirmed
5499            // fresh in time - which is evidence about the machine, not about
5500            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5501            // Recording it as a red gate would mark a run `Blocked` on nothing
5502            // but contention magi has already logged; leaving `self.state.gate`
5503            // empty and `self.state.gate_ran` false instead keeps the shape
5504            // this function already treats as "still needs to run" (see the
5505            // early-return above), so the next call retries the command
5506            // rather than concluding anything.
5507            if verify_inconclusive(&outcomes) {
5508                self.state.save()?;
5509                return Ok(());
5510            }
5511            if outcomes.iter().all(CommandOutcome::ok) {
5512                break;
5513            }
5514            match self.gate_fix_round(&winner, &outcomes).await? {
5515                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5516                GateFix::Stop => break,
5517                GateFix::Defer => {
5518                    self.state.save()?;
5519                    return Ok(());
5520                }
5521            }
5522        }
5523        let passed = outcomes.iter().all(CommandOutcome::ok);
5524        self.state.gate = outcomes;
5525        self.state.gate_ran = true;
5526        if !passed {
5527            self.state.status = RunStatus::Blocked;
5528            let spent = self.state.gate_fixes.len();
5529            self.state.event(
5530                "gate",
5531                if spent == 0 {
5532                    "gate failed; not merging".to_owned()
5533                } else {
5534                    format!("gate failed after {spent} gate-fix round(s); not merging")
5535                },
5536            );
5537        }
5538        self.state.save()?;
5539        Ok(())
5540    }
5541
5542    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
5543    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
5544    /// after review is clean and never on a candidate awaiting judging.
5545    ///
5546    /// Never fails the run: a non-zero exit or timeout is a warning and a
5547    /// recorded outcome, and the gate remains the single arbiter. Nothing
5548    /// configured means nothing happens - no event, no commit. `commit_all`
5549    /// commits any leftover change under the neutral identity and returns
5550    /// `false` when the tree is clean, so no empty commit is ever made.
5551    async fn run_pre_gate(&mut self, winner: &Candidate) {
5552        let commands = self.state.config.verify.pre_gate.clone();
5553        if commands.is_empty() {
5554            return;
5555        }
5556        let shell = self.state.config.shell();
5557        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5558        let (outcomes, _) = run_commands(
5559            &mut self.state,
5560            "pre_gate",
5561            "pre_gate",
5562            0,
5563            &shell,
5564            &commands,
5565            &winner.worktree,
5566            timeout,
5567        )
5568        .await;
5569        for o in &outcomes {
5570            if !o.ok() {
5571                tracing::warn!(
5572                    "pre_gate `{}` failed ({:?}); the gate decides",
5573                    o.command,
5574                    o.code
5575                );
5576            }
5577            self.state.event(
5578                "pre_gate",
5579                format!(
5580                    "`{}` -> {}",
5581                    o.command,
5582                    if o.ok() {
5583                        "pass".to_owned()
5584                    } else {
5585                        format!(
5586                            "FAIL ({:?})\n{}",
5587                            o.code,
5588                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5589                        )
5590                    }
5591                ),
5592            );
5593        }
5594        self.state.pre_gate = outcomes;
5595        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5596            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5597                Ok(head) => {
5598                    self.state
5599                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
5600                    self.state.pre_gate_commit = Some(head);
5601                }
5602                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5603            },
5604            Ok(false) => {}
5605            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5606        }
5607        if let Err(e) = self.state.save() {
5608            tracing::warn!("could not persist the pre_gate record: {e:#}");
5609        }
5610    }
5611
5612    /// Run `verify.gate` once against the winner's current tree, logging one
5613    /// event per command. Empty when nothing is configured.
5614    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5615        self.run_pre_gate(winner).await;
5616        let shell = self.state.config.shell();
5617        let gate_commands = self.state.config.verify.gate.clone();
5618        // Zero commands has nothing to run and nothing that could touch the
5619        // shared build cache, so it never needs a lease: `Config::cache_dir`
5620        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
5621        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
5622        // otherwise queue behind an unrelated run's lease and come back
5623        // resource-blocked - `gate_ran` would stay false on nothing but
5624        // cache contention, for a step that had nothing to check in the
5625        // first place.
5626        let outcomes = if gate_commands.is_empty() {
5627            Vec::new()
5628        } else {
5629            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5630            let cache_dir = self.state.config.cache_dir();
5631            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5632            let (outcomes, _) = with_cache_lease(
5633                &mut self.state,
5634                cache_dir.as_deref(),
5635                "gate",
5636                "gate",
5637                &winner.worktree,
5638                &head,
5639                timeout,
5640                "final gate",
5641                |state, budget| {
5642                    let shell = shell.clone();
5643                    let gate_commands = gate_commands.clone();
5644                    let worktree = winner.worktree.clone();
5645                    async move {
5646                        let (outcomes, timed_out_pids) = run_commands(
5647                            state,
5648                            "gate",
5649                            "gate",
5650                            0,
5651                            &shell,
5652                            &gate_commands,
5653                            &worktree,
5654                            budget,
5655                        )
5656                        .await;
5657                        (outcomes, false, timed_out_pids)
5658                    }
5659                },
5660            )
5661            .await;
5662            outcomes
5663        };
5664        if outcomes.is_empty() {
5665            // Nothing configured to check — distinct from every other
5666            // silence in this run's event log, since an empty `gate` alone
5667            // no longer says whether the gate ran at all (see
5668            // `RunState::gate_ran`'s own doc).
5669            self.state.event(
5670                "gate",
5671                "no gate commands configured; nothing to check, passing",
5672            );
5673        }
5674        for o in &outcomes {
5675            self.state.event(
5676                "gate",
5677                format!(
5678                    "`{}` -> {}",
5679                    o.command,
5680                    if o.ok() {
5681                        "pass".to_owned()
5682                    } else {
5683                        format!(
5684                            "FAIL ({:?})\n{}",
5685                            o.code,
5686                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5687                        )
5688                    }
5689                ),
5690            );
5691        }
5692        Ok(outcomes)
5693    }
5694
5695    /// One bounded fix round for a failing gate.
5696    ///
5697    /// The fixer is told the failure came from the gate itself, not from a
5698    /// reviewer, and is shown the failed commands, their exit codes and a tail
5699    /// of their output - whatever `[verify].gate` holds, nothing here knows
5700    /// what those commands run. Only a normal non-zero exit that printed
5701    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
5702    /// command or a full disk says nothing about the code, and a fixer sent
5703    /// after it can only appease the machine. The round is judged by what git
5704    /// says moved, never by the fixer's own report, and `verify.e2e` runs
5705    /// again before the gate does, so a fix cannot trade a green gate for a
5706    /// red e2e unnoticed.
5707    async fn gate_fix_round(
5708        &mut self,
5709        winner: &Candidate,
5710        outcomes: &[CommandOutcome],
5711    ) -> Result<GateFix> {
5712        let cap = self.state.config.graph.gate_fix_rounds;
5713        let spent = self.state.gate_fixes.len();
5714        if spent >= cap {
5715            if cap > 0 {
5716                self.state.event(
5717                    "gate",
5718                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
5719                );
5720            }
5721            return Ok(GateFix::Stop);
5722        }
5723        if !gate_fixable(outcomes) {
5724            self.state.event(
5725                "gate",
5726                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5727                 command or similar); not spending a fix round on it",
5728            );
5729            return Ok(GateFix::Stop);
5730        }
5731        let min_free = self.state.config.disk.min_free_bytes;
5732        if min_free > 0 {
5733            match crate::disk::free_bytes(&winner.worktree) {
5734                Ok(free) if crate::disk::enough_space(free, min_free) => {}
5735                Ok(free) => {
5736                    self.state.event(
5737                        "gate",
5738                        format!(
5739                            "only {free} bytes free ({min_free} required by `[disk] \
5740                             min_free_bytes`); not spending a fix round on a failure the disk \
5741                             may explain"
5742                        ),
5743                    );
5744                    return Ok(GateFix::Stop);
5745                }
5746                Err(e) => {
5747                    self.state.event(
5748                        "gate",
5749                        format!("free disk space could not be measured ({e:#}); no fix round"),
5750                    );
5751                    return Ok(GateFix::Stop);
5752                }
5753            }
5754        }
5755
5756        let attempt = spent + 1;
5757        let run_id = self.state.id.clone();
5758        let prompts = self.state.config.prompts.clone();
5759        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5760        let base = self.landing_base();
5761        let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5762        let seat = self.seat(&fix_seat_key, &fix_spec.id);
5763        let job = SeatJob {
5764            prompt: prompt::gate_fix(
5765                &self.state.instruction,
5766                &failed,
5767                attempt,
5768                cap,
5769                &self.state.config.graph.language,
5770            ),
5771            spec: fix_spec,
5772            seat,
5773            cwd: winner.worktree.clone(),
5774            timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5775            allow_write: true,
5776            sessions: self.state.config.graph.sessions,
5777            artifacts: agent::artifacts_dir(&self.state.dir()),
5778            stem: format!("gate-fix-{attempt}"),
5779            handover: None,
5780        };
5781        self.state.event(
5782            "gate",
5783            format!("gate failed; gate-fix round {attempt} of {cap}"),
5784        );
5785        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5786        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5787        let cache = self.state.config.cache_dir();
5788        let ctx = WaveCtx {
5789            carry_seats: false,
5790            run: &run_id,
5791            node: "gate-fix",
5792            prompts: &prompts,
5793            cache: cache.as_deref(),
5794            round: None,
5795        };
5796        let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5797        let mut record = GateFixRecord {
5798            agent: seat.agent.clone(),
5799            failed,
5800            notes: String::new(),
5801            committed: false,
5802            error: None,
5803        };
5804        match out {
5805            AgentOutcome::Ok(o) => {
5806                // A missing report is not a failed fix: the round is judged
5807                // by the tree below, and the report only carries prose.
5808                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5809                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5810                }
5811            }
5812            AgentOutcome::Dropped(_) => {
5813                record.error = Some("the CLI dropped the stream".to_owned());
5814            }
5815            AgentOutcome::Quota(o) => {
5816                self.state.quota.push(QuotaLoss {
5817                    seat: seat.key.clone(),
5818                    node: "gate-fix".to_owned(),
5819                    at: Timestamp::now(),
5820                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5821                });
5822                record.error = Some("rate limited (quota); fixer could not run".to_owned());
5823            }
5824            AgentOutcome::Failed(e) => record.error = Some(e),
5825        }
5826        self.state.seats.insert(seat.key.clone(), seat);
5827        if let Ok(r) = git::rescue_commit(
5828            &winner.worktree,
5829            &format!("magi: gate fix {attempt} (uncommitted work)"),
5830        )
5831        .await
5832        {
5833            self.state.note_withheld("gate-fix", &r.withheld);
5834        }
5835        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5836        record.committed = after != before;
5837        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5838        let note = record.error.clone();
5839        self.state.gate_fixes.push(record);
5840        self.state.save()?;
5841        if !changed {
5842            self.state.event(
5843                "gate",
5844                match note {
5845                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5846                    None => format!("gate-fix round {attempt}: the tree did not change"),
5847                },
5848            );
5849            return Ok(GateFix::Stop);
5850        }
5851        self.state.event(
5852            "gate",
5853            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5854        );
5855
5856        let commands = self.state.config.verify.e2e.clone();
5857        if !commands.is_empty() {
5858            let shell = self.state.config.shell();
5859            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5860            let cache_dir = self.state.config.cache_dir();
5861            let context = format!("gate-fix round {attempt}");
5862            let (e2e, _) = with_cache_lease(
5863                &mut self.state,
5864                cache_dir.as_deref(),
5865                "e2e",
5866                "e2e",
5867                &winner.worktree,
5868                &after,
5869                timeout,
5870                &context,
5871                |state, budget| {
5872                    let shell = shell.clone();
5873                    let commands = commands.clone();
5874                    let context = context.clone();
5875                    let worktree = winner.worktree.clone();
5876                    async move {
5877                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5878                            .await
5879                    }
5880                },
5881            )
5882            .await;
5883            if verify_inconclusive(&e2e) {
5884                return Ok(GateFix::Defer);
5885            }
5886            if e2e.iter().any(|o| !o.ok()) {
5887                self.state.event(
5888                    "gate",
5889                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5890                );
5891                return Ok(GateFix::Stop);
5892            }
5893        }
5894        Ok(GateFix::Retry)
5895    }
5896
5897    // ---------------------------------------------------------------- merge
5898
5899    async fn merge(&mut self) -> Result<()> {
5900        // Same reasoning as `gate`: ask the review and gate records directly
5901        // rather than `status`, which a solo-candidate `judge`/`deliberate`
5902        // skip can rewrite on reentry to something that no longer says
5903        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
5904        // so a hand-off (open findings, green verification) reaches merge
5905        // exactly like a genuinely clean round does.
5906        //
5907        // A run resumed mid-`land` never reaches here at all: `execute`
5908        // recognises `RunStatus::Landing` before it even calls `prep`, and
5909        // routes straight to `run_land` instead. That has to happen a level
5910        // up from this function, not with a check in here, because
5911        // `review_loop`'s own status recomputation (see its doc) runs
5912        // *before* `merge` on every reentry and would otherwise overwrite
5913        // the `Landing` marker with `Gating` before this node ever saw it.
5914        if self
5915            .state
5916            .base_sync
5917            .as_ref()
5918            .is_some_and(|s| s.conflict.is_some())
5919            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5920                != Some(RunStatus::Gating)
5921            // `gate_ran == false` is not "passed" - `gate` leaves it false
5922            // both before it has ever run and when its last attempt was
5923            // resource-blocked (see `Runner::gate`'s own doc), and neither is
5924            // permission to merge on nothing but the review record. Only a
5925            // gate that actually ran - zero commands configured and
5926            // vacuously passed, or one or more that all exited 0 - may
5927            // proceed; `RunState::gate_status` is the single place that
5928            // reading is computed.
5929            || !self.state.gate_status().ok()
5930        {
5931            return Ok(());
5932        }
5933        // This node's own record, not `status`: `status == Ready` is not
5934        // unique to the harmless `MergeMode::None` path this line was
5935        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
5936        // run's PR was closed without merging — and on that run `mode` is
5937        // still `Pr`, so a reentry that fell through here would push and
5938        // open a second pull request. `self.state.merge` is set exactly once
5939        // this node (or `land`) has already produced a verdict, under every
5940        // mode, which is what "already done" actually means here.
5941        if self.state.merge.is_some() {
5942            return Ok(());
5943        }
5944        let Some(winner) = self.state.winner().cloned() else {
5945            return Ok(());
5946        };
5947        let repo = self.state.repo.clone();
5948        let base = self.state.base_branch.clone();
5949        let mode = self.state.config.merge.mode;
5950        let style = self.state.config.merge.style;
5951        let facts = if is_review_run(&self.state) {
5952            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
5953            let start = review_base(
5954                &repo,
5955                &self.state.config.merge.remote,
5956                &base,
5957                &self.state.base_commit,
5958                &winner.branch,
5959            )
5960            .await;
5961            branch_facts(&repo, &start, &winner.branch).await
5962        } else {
5963            None
5964        };
5965        // `None` when the base could not be freshly read: then an adopted
5966        // pull request's title is left alone.
5967        let leaked = if is_review_run(&self.state) {
5968            leaked_subjects(&self.state, &winner.branch).await
5969        } else {
5970            Some(Vec::new())
5971        };
5972        let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5973        let message = pr.commit_message();
5974
5975        let outcome = match mode {
5976            MergeMode::None => MergeOutcome {
5977                mode,
5978                ok: true,
5979                detail: manual_merge_command(style, &repo, &winner.branch, &message),
5980                empty: false,
5981            },
5982            MergeMode::Pr | MergeMode::Local
5983                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5984            {
5985                MergeOutcome {
5986                    mode,
5987                    ok: false,
5988                    detail: empty_candidate_detail(&self.state, &base),
5989                    empty: true,
5990                }
5991            }
5992            MergeMode::Local => {
5993                let on = git::current_branch(&repo).await?;
5994                if on.as_deref() != Some(base.as_str()) {
5995                    MergeOutcome {
5996                        mode,
5997                        ok: false,
5998                        detail: format!(
5999                            "{} has {} checked out, not the base branch {base}",
6000                            repo.display(),
6001                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
6002                        ),
6003                        empty: false,
6004                    }
6005                } else if !git::is_clean(&repo).await? {
6006                    MergeOutcome {
6007                        mode,
6008                        ok: false,
6009                        detail: format!("{} is dirty; refusing to merge", repo.display()),
6010                        empty: false,
6011                    }
6012                } else {
6013                    let out = match style {
6014                        MergeStyle::Merge => {
6015                            git::merge_no_ff(&repo, &winner.branch, &message).await?
6016                        }
6017                        MergeStyle::Squash => {
6018                            git::merge_squash(&repo, &winner.branch, &message).await?
6019                        }
6020                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6021                    };
6022                    MergeOutcome {
6023                        mode,
6024                        ok: out.ok(),
6025                        detail: if out.ok() { out.stdout } else { out.stderr },
6026                        empty: false,
6027                    }
6028                }
6029            }
6030            MergeMode::Pr => {
6031                let remote = self.state.config.merge.remote.clone();
6032                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6033                if !pushed.ok() {
6034                    MergeOutcome {
6035                        mode,
6036                        ok: false,
6037                        detail: pushed.stderr,
6038                        empty: false,
6039                    }
6040                } else {
6041                    // A retry or resume of a run whose branch already has an
6042                    // open pull request adopts it rather than failing on a
6043                    // duplicate. Only this winner branch into this base:
6044                    // `branch_for` derives the name from the run id, so a
6045                    // different run's pull request never matches.
6046                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6047                    let out = match pr_merge_plan(found) {
6048                        PrPlan::Create => {
6049                            gh_pr_create(
6050                                &winner.worktree,
6051                                &base,
6052                                &winner.branch,
6053                                &pr.title,
6054                                &pr.body,
6055                            )
6056                            .await
6057                        }
6058                        PrPlan::Adopt { url, title } => {
6059                            self.state
6060                                .event("merge", format!("Pr: adopted open pull request {url}"));
6061                            if title != pr.title
6062                                && (!is_review_run(&self.state)
6063                                    || leaked
6064                                        .as_deref()
6065                                        .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6066                                && let Err(e) =
6067                                    land::set_pr_title(&winner.worktree, &url, &pr.title).await
6068                            {
6069                                tracing::warn!("could not refresh title of {url}: {e:#}");
6070                                self.state
6071                                    .event("merge", format!("Pr: title refresh failed: {e:#}"));
6072                            }
6073                            Ok(url)
6074                        }
6075                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6076                    };
6077                    match out {
6078                        Ok(url) => MergeOutcome {
6079                            mode,
6080                            ok: true,
6081                            detail: url,
6082                            empty: false,
6083                        },
6084                        Err(e) => MergeOutcome {
6085                            mode,
6086                            ok: false,
6087                            detail: e.to_string(),
6088                            empty: false,
6089                        },
6090                    }
6091                }
6092            }
6093        };
6094
6095        self.state.status = match (mode, outcome.ok) {
6096            (MergeMode::None, _) => RunStatus::Ready,
6097            (_, true) => RunStatus::Merged,
6098            (_, false) => RunStatus::Blocked,
6099        };
6100        self.state.event(
6101            "merge",
6102            format!(
6103                "{:?}: {}",
6104                mode,
6105                outcome.detail.lines().next().unwrap_or("")
6106            ),
6107        );
6108        self.state.merge = Some(outcome);
6109        self.state.save()?;
6110
6111        // The PR is open and the run would historically stop here, leaving the
6112        // operator to watch checks, feed review comments back to a fixer, and
6113        // merge. That was done by hand six times in one session before this
6114        // existed. Opt-in, because merging is the one irreversible thing magi
6115        // can do to a repository.
6116        if self.state.config.graph.land
6117            && mode == MergeMode::Pr
6118            && self.state.status == RunStatus::Merged
6119        {
6120            self.run_land().await?;
6121        }
6122        // `run_land` may have left `status` at `Landing` - still waiting on
6123        // CI or the owner's approval, not actually settled - so this has to
6124        // read whatever `status` ended up as here, not the `Merged` this
6125        // function set a few lines up.
6126        self.settle_questions();
6127        Ok(())
6128    }
6129
6130    /// Enter `land`.
6131    ///
6132    /// Shared between a fresh run's first pass through [`Runner::merge`] and
6133    /// a resumed run's re-entry. `land::land` itself is what serialises the
6134    /// two git-mutating moments inside the loop — the rebase push and
6135    /// `gh pr merge` — per repository (see its own doc); nothing here needs
6136    /// to hold a lock across the whole call, and doing so would serialise
6137    /// this run's CI wait against a *different* run's land-approval resume
6138    /// in the same repository, which is exactly the "must not wait on
6139    /// another task" property the daemon's slot-freeing exists to give.
6140    async fn run_land(&mut self) -> Result<()> {
6141        let url = self
6142            .state
6143            .merge
6144            .as_ref()
6145            .map(|m| m.detail.clone())
6146            .unwrap_or_default();
6147        let url = url.lines().next().unwrap_or("").trim().to_owned();
6148        if !url.starts_with("http") {
6149            return Ok(());
6150        }
6151        // A land failure is not a lost run: the work is on a branch and the
6152        // pull request is open, which is exactly where a human takes over.
6153        match land::land(&mut self.state, &url).await {
6154            Ok(pr) if self.state.parked => {
6155                // `land` already saved the parked marker; nothing here
6156                // overrides `status` back to a terminal value while an
6157                // approval is still outstanding.
6158                let _ = pr;
6159            }
6160            Ok(pr) => {
6161                self.state.status = match pr.state {
6162                    land::PrLifecycle::Merged => RunStatus::Merged,
6163                    _ => RunStatus::Blocked,
6164                };
6165                // Downstream of a confirmed merge only - see
6166                // `bump::should_release_bump`'s own doc for why this one
6167                // check covers all three of `land`'s success paths.
6168                // Best-effort: the run already landed, so a failure here
6169                // (the decision call, `gh`, `cargo`) is recorded and never
6170                // turns a landed run into a failed one.
6171                if bump::should_release_bump(self.state.status)
6172                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6173                {
6174                    // The event is the run's own record. Not-eligible cases
6175                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
6176                    // `Err` is a bump that was tried and failed:
6177                    // `after_merge` itself raises the operator notice for
6178                    // that, whether or not a release PR exists yet.
6179                    self.state
6180                        .event("bump", format!("release bump skipped: {e:#}"));
6181                }
6182                // Independent of the bump, and best-effort in the same way:
6183                // findings the merge left open become follow-up tasks.
6184                if self.state.status == RunStatus::Merged {
6185                    crate::followup::after_merge(&mut self.state, &pr.url).await;
6186                }
6187                self.state.save()?;
6188            }
6189            Err(e) => {
6190                self.state.status = RunStatus::Blocked;
6191                self.state.event("land", format!("gave up: {e}"));
6192                self.state.save()?;
6193            }
6194        }
6195        Ok(())
6196    }
6197
6198    // -------------------------------------------------------------- helpers
6199
6200    /// Fetch or create a seat, keeping its conversation across nodes.
6201    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6202        if let Some(existing) = self.state.seats.get(key)
6203            && existing.agent == agent
6204        {
6205            return existing.clone();
6206        }
6207        // A seat that changes agent mints its session id from the agent too,
6208        // like a handover: the old agent's uuid is already taken by the CLI.
6209        let fresh = if self.state.seats.contains_key(key) {
6210            handover_seat(key, agent, self.state.next_seat_seed())
6211        } else {
6212            SeatState::new(key, agent, self.state.seed)
6213        };
6214        self.state.seats.insert(key.to_owned(), fresh.clone());
6215        fresh
6216    }
6217
6218    /// The agent now holding seat `key`: `spec`, unless a handover moved the
6219    /// seat to another roster agent, in which case that agent. Nodes that
6220    /// continue a seat's conversation (deliberation, the votes, a reviewer's
6221    /// reconsideration) must keep talking to whoever answered it, not slip
6222    /// back to the agent that failed it.
6223    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6224        match self.state.seats.get(key) {
6225            Some(s) if s.agent != spec.id => {
6226                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6227            }
6228            _ => spec,
6229        }
6230    }
6231
6232    /// A candidate rendered for judging, with the leak policy applied.
6233    fn view(&self, c: &Candidate) -> CandidateView {
6234        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6235            .unwrap_or_default();
6236        let (patch, _) = blind::sanitize_patch(
6237            &format!("candidate {} patch", c.label),
6238            &raw,
6239            &self.state.config.blind,
6240        );
6241        CandidateView {
6242            label: c.label,
6243            branch: c.branch.clone(),
6244            summary: c.summary.clone(),
6245            stat: c.stat.clone(),
6246            patch,
6247        }
6248    }
6249
6250    /// The full candidate set as prompt text, for seats with no live session.
6251    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6252        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6253        prompt::judge(
6254            "(see above)",
6255            &views,
6256            self.roles.judges.len(),
6257            base_short,
6258            "en",
6259        )
6260    }
6261
6262    /// The final-vote prompt with everything a seat that has no session of its
6263    /// own needs: the candidates, the seat's own ranking and reasons, and the
6264    /// anonymised deliberation it took part in (only when there was one, so a
6265    /// handed-over seat never sees more than the seat it replaces did). The
6266    /// `Final vote` heading stays first.
6267    fn vote_prompt_full(
6268        &self,
6269        j: usize,
6270        labels: &[char],
6271        language: &str,
6272        candidates: &[Candidate],
6273        base_short: &str,
6274    ) -> String {
6275        let mut text = format!(
6276            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6277            prompt::final_vote(labels, language),
6278            self.state.instruction,
6279            self.candidate_block(candidates, base_short)
6280        );
6281        if let Some(own) = self
6282            .state
6283            .judgements
6284            .get(j)
6285            .filter(|r| !r.ranking.is_empty())
6286        {
6287            let reasons = own
6288                .reasons
6289                .iter()
6290                .map(|(k, v)| format!("- {k}: {v}"))
6291                .collect::<Vec<_>>()
6292                .join("\n");
6293            text.push_str(&format!(
6294                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6295                own.ranking.iter().collect::<String>(),
6296                if reasons.is_empty() {
6297                    ""
6298                } else {
6299                    ", because:\n"
6300                }
6301            ));
6302        }
6303        if !self.state.deliberation.is_empty() {
6304            text.push_str("\n# What was argued before this vote\n");
6305            for t in self.transcript(&[], j) {
6306                text.push_str(&format!(
6307                    "\n## {}{}\n\n{}\n",
6308                    t.who,
6309                    if t.is_self { " (you)" } else { "" },
6310                    t.body.trim()
6311                ));
6312            }
6313        }
6314        text
6315    }
6316
6317    /// Anonymised transcript for judge `self_idx`.
6318    ///
6319    /// The initial rankings are always the opening statements. Seeding them
6320    /// only when no turn had been taken yet meant every judge after the first
6321    /// argued against a single voice instead of against the actual split — the
6322    /// disagreement is the information, so it is always on the table.
6323    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6324        let mut turns = Vec::new();
6325        for j in &self.state.judgements {
6326            if j.ranking.is_empty() {
6327                continue;
6328            }
6329            let reasons = j
6330                .reasons
6331                .iter()
6332                .map(|(k, v)| format!("- {k}: {v}"))
6333                .collect::<Vec<_>>()
6334                .join("\n");
6335            turns.push(Turn {
6336                who: format!("Judge {} (opening ranking)", j.judge),
6337                is_self: j.judge == self_idx + 1,
6338                body: format!(
6339                    "Ranked {}{}{reasons}",
6340                    j.ranking.iter().collect::<String>(),
6341                    if reasons.is_empty() {
6342                        ""
6343                    } else {
6344                        ", because:\n"
6345                    }
6346                ),
6347            });
6348        }
6349        for t in self
6350            .state
6351            .deliberation
6352            .iter()
6353            .flat_map(|r| r.turns.iter())
6354            .chain(current)
6355        {
6356            turns.push(Turn {
6357                who: format!("Judge {}", t.judge),
6358                is_self: t.judge == self_idx + 1,
6359                body: t.body.clone(),
6360            });
6361        }
6362        turns
6363    }
6364}
6365
6366/// Does this seat still hold the context a follow-up prompt would rely on?
6367fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6368    agent::has_session(spec.kind, seat, sessions)
6369}
6370
6371/// The next entry in `roster` after `start`, never wrapping back to the
6372/// front, whose id is not in `tried` yet.
6373///
6374/// Starts one past `start` rather than at the front of `roster`: `start` is
6375/// the seat's own original position, and a seat whose candidate slot already
6376/// sits on the roster's second entry must fall through to the third next, not
6377/// restart at the first — which is very likely a different candidate's own
6378/// agent already. Never wraps back past `start`, for the same reason: an
6379/// entry earlier in the roster than the seat's own position is almost
6380/// certainly some *other* candidate slot's own agent, and once the tail of
6381/// the roster is exhausted there are no more untried agents for *this* seat
6382/// to fall through to — the caller's fallback chain ends there, exactly as
6383/// "no further untried agents remain in the list for that seat" asks for.
6384///
6385/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
6386/// the same id twice (an operator's `roles.implementers` typo, or a
6387/// `[[agents]]` list reused across roles) must not let
6388/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
6389/// over `roster` either finds an untried id or runs out, so this always
6390/// terminates regardless of duplicates.
6391fn next_untried_in_roster<'a>(
6392    roster: &'a [AgentSpec],
6393    start: usize,
6394    tried: &BTreeSet<String>,
6395) -> Option<&'a AgentSpec> {
6396    roster
6397        .get(start + 1..)?
6398        .iter()
6399        .find(|s| !tried.contains(&s.id))
6400}
6401
6402/// The next agent for a seat that carries its failure history across rounds
6403/// (the review loop). `round_tried` is this round's own bound and starts
6404/// empty every round; `carried_failed` only decides priority.
6405///
6406/// First: an id walking forward from `start`, never wrapping, that is neither
6407/// tried this round nor failed in an earlier one. Only when that is exhausted
6408/// does it rescue: the first roster id (in roster order, so this one *does*
6409/// look before `start`) not yet tried this round, which is by then a carried
6410/// failure. Each id is rescued at most once per round, so it cannot loop.
6411fn next_for_seat<'a>(
6412    roster: &'a [AgentSpec],
6413    start: usize,
6414    round_tried: &BTreeSet<String>,
6415    carried_failed: &BTreeSet<String>,
6416) -> Option<&'a AgentSpec> {
6417    roster
6418        .get(start + 1..)?
6419        .iter()
6420        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6421        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6422}
6423
6424/// Where a reviewer seat starts a round: the agent that last answered it when
6425/// it is still on the roster and not marked failed, else the spec's own agent
6426/// unless it failed, else the next roster agent that has not failed, else the
6427/// spec's own agent again (the whole roster failed). Ids no longer on the
6428/// roster are ignored. An empty roster has no handover, so the spec stands.
6429fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6430    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6431        return spec;
6432    };
6433    let ok = |id: &str| !h.failed.contains(id);
6434    if let Some(last) = h.last_ok.as_deref()
6435        && ok(last)
6436        && let Some(s) = roster.iter().find(|s| s.id == last)
6437    {
6438        return s.clone();
6439    }
6440    if ok(&spec.id) {
6441        return spec;
6442    }
6443    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6444    roster
6445        .get(from + 1..)
6446        .into_iter()
6447        .flatten()
6448        .chain(roster.iter())
6449        .find(|s| ok(&s.id))
6450        .cloned()
6451        .unwrap_or(spec)
6452}
6453
6454/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
6455/// seed so a CLI that mints its session id up front (`--session-id`) never
6456/// reuses the uuid the previous agent already opened under the same seat key.
6457fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6458    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6459}
6460
6461/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
6462/// so the classifier below and the code that builds the message cannot drift.
6463const TIMED_OUT: &str = "timed out";
6464
6465impl FailClass {
6466    /// `None` for an answer; otherwise how the turn failed.
6467    fn of(out: &AgentOutcome) -> Option<Self> {
6468        match out {
6469            AgentOutcome::Ok(_) => None,
6470            AgentOutcome::Quota(_) => Some(Self::Quota),
6471            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6472            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6473            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6474        }
6475    }
6476
6477    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
6478    fn stem_word(&self) -> &'static str {
6479        match self {
6480            Self::Quota => "quota",
6481            _ => "handover",
6482        }
6483    }
6484}
6485
6486/// The message's first line with its variable parts removed — digit runs and
6487/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
6488/// as one kind of failure.
6489fn failure_signature(msg: &str) -> String {
6490    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6491    let mut out = Vec::new();
6492    for word in line.split_whitespace() {
6493        if word.contains('/') || word.contains('\\') {
6494            out.push("<path>".to_owned());
6495            continue;
6496        }
6497        let mut w = String::new();
6498        let mut in_digits = false;
6499        for c in word.chars() {
6500            if c.is_ascii_digit() {
6501                if !in_digits {
6502                    w.push('#');
6503                }
6504                in_digits = true;
6505            } else {
6506                in_digits = false;
6507                w.push(c);
6508            }
6509        }
6510        out.push(w);
6511    }
6512    out.join(" ").chars().take(120).collect()
6513}
6514
6515/// Whether a seat that just failed with `cur` may go to the next roster agent.
6516/// A quota or a timeout always may. Any other failure may not when the agent
6517/// before it failed the same way: an error the prompt causes would otherwise
6518/// walk the whole roster. `prev` is the class of the immediately preceding
6519/// agent's failure, so a quota or timeout in between breaks the run of
6520/// identical failures by itself.
6521fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6522    match cur {
6523        FailClass::Quota | FailClass::Timeout => true,
6524        FailClass::Other(_) => prev != Some(cur),
6525    }
6526}
6527
6528/// A short human reason for a failed outcome, for the handover record.
6529fn fail_reason(out: &AgentOutcome) -> String {
6530    match out {
6531        AgentOutcome::Ok(_) => String::new(),
6532        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6533        AgentOutcome::Dropped(o) => format!(
6534            "the CLI dropped the stream ({})",
6535            o.dropped
6536                .as_ref()
6537                .map(|d| d.why.as_str())
6538                .unwrap_or("it ended without delivering its answer")
6539        ),
6540        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6541    }
6542}
6543
6544/// Note one handover in the run: the structured record and, in the timeline,
6545/// the sentence a person reads. A quota keeps the wording it always had.
6546fn record_handover(
6547    state: &mut RunState,
6548    node: &str,
6549    seat: &str,
6550    from: &str,
6551    to: &str,
6552    class: &FailClass,
6553    reason: &str,
6554) {
6555    let message = if *class == FailClass::Quota {
6556        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6557    } else {
6558        format!("{seat}: handed over {from} -> {to} ({reason})")
6559    };
6560    state.event(node, message);
6561    state.handovers.push(Handover {
6562        at: Timestamp::now(),
6563        node: node.to_owned(),
6564        seat: seat.to_owned(),
6565        from: from.to_owned(),
6566        to: to.to_owned(),
6567        reason: reason.to_owned(),
6568    });
6569}
6570
6571/// Did this reply report running a command whose own CLI never confirmed an
6572/// exit status?
6573///
6574/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
6575/// command *finished* (see that type's own doc), so this can only be `true`
6576/// for a command whose completion event carried no readable exit code — not
6577/// for one that simply is not mentioned at all. That is the one signal this
6578/// crate can read, from the same record `state.jobs` renders, about a reply
6579/// standing next to work its own CLI cannot vouch for finishing; it is
6580/// deliberately not a check on the exit code's *value* (a fixer legitimately
6581/// runs a command that fails mid-iteration before it succeeds) and not a
6582/// guess at a command still running in the background (which emits no event
6583/// at all, and so leaves no evidence here to find).
6584fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6585    commands.iter().any(|c| c.exit_code.is_none())
6586}
6587
6588/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
6589/// trusted as a verified no-op — the adoption guard's own text-level half.
6590///
6591/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
6592/// timed out): a marker only earns the benefit of the doubt from a turn the
6593/// CLI itself vouches for finishing properly, the same house style
6594/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
6595/// *fix* report to for `commands`. A candidate that timed out, exited
6596/// non-zero, or left a command unconfirmed is read as the ordinary loss it
6597/// is, whatever prose it wrote — this returns `None` before it ever looks at
6598/// `text`. The remaining guards (the tree really is empty, the evidence is
6599/// non-empty) are the caller's: this only reads what the reply *claimed*.
6600fn verified_noop_claim(
6601    usable: bool,
6602    commands: &[agent::CommandEvidence],
6603    text: &str,
6604) -> Option<String> {
6605    (usable && !has_unconfirmed_command(commands))
6606        .then(|| verdict::verified_noop(text))
6607        .flatten()
6608}
6609
6610fn short(commit: &str) -> String {
6611    commit.chars().take(7).collect()
6612}
6613
6614fn make_executable(path: &Path) -> Result<()> {
6615    #[cfg(unix)]
6616    {
6617        use std::os::unix::fs::PermissionsExt as _;
6618        let mut perms = std::fs::metadata(path)?.permissions();
6619        perms.set_mode(0o755);
6620        std::fs::set_permissions(path, perms)?;
6621    }
6622    #[cfg(not(unix))]
6623    {
6624        let _ = path;
6625    }
6626    Ok(())
6627}
6628
6629/// What every seat in one batch shares: where the answers are attributed, the
6630/// prompt overlay they inherit, and the build cache they are told to use.
6631///
6632/// A struct rather than four more parameters: `wave` also needs the run's
6633/// state (to record who is answering right now) and the attempt number, and
6634/// eight positional arguments is both unreadable and a clippy error.
6635struct WaveCtx<'a> {
6636    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
6637    /// paid for it.
6638    run: &'a str,
6639    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
6640    node: &'a str,
6641    prompts: &'a Prompts,
6642    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
6643    cache: Option<&'a Path>,
6644    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
6645    /// `JobRecord::round`. `None` for every other node.
6646    round: Option<usize>,
6647    /// Carry each seat's failed-agent history across waves (the review loop
6648    /// only): start-of-round priority and handover choice read
6649    /// [`RunState::seat_history`], and every answer or failure writes it.
6650    carry_seats: bool,
6651}
6652
6653/// Run one job, honouring the parallelism budget.
6654async fn run_one(
6655    job: SeatJob,
6656    sem: Arc<Semaphore>,
6657    ctx: &WaveCtx<'_>,
6658    state: &mut RunState,
6659    attempt: usize,
6660) -> (SeatState, AgentOutcome) {
6661    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6662        .await
6663        .pop()
6664        .expect("one job in, one result out");
6665    (seat, out)
6666}
6667
6668/// Run every job concurrently, capped by the semaphore, preserving order.
6669///
6670/// Every seat in the batch is recorded into [`RunState::active`] before the
6671/// wave starts and cleared as each answer lands, so the run's own record says
6672/// who is still being waited on rather than only who finished.
6673async fn wave(
6674    jobs: Vec<SeatJob>,
6675    sem: Arc<Semaphore>,
6676    ctx: &WaveCtx<'_>,
6677    state: &mut RunState,
6678    attempt: usize,
6679) -> Vec<(usize, SeatState, AgentOutcome)> {
6680    let WaveCtx {
6681        run,
6682        node,
6683        prompts,
6684        cache,
6685        round,
6686        carry_seats: _,
6687    } = *ctx;
6688    for job in &jobs {
6689        state.seat_started(node, &job.seat.key, job.timeout, attempt);
6690    }
6691    if let Err(e) = state.save() {
6692        // A failed persist of "who is answering right now" must not abort the
6693        // wave: the seats are already being asked, and the alternative is
6694        // losing the answers to save a status line nobody may even be
6695        // watching.
6696        tracing::warn!("could not persist in-progress seats: {e:#}");
6697    }
6698    // Hold the shared build cache's lease for the whole batch, not per job:
6699    // several candidates (an implement wave) or a fixer legitimately share
6700    // one cache concurrently within this run, and that stays untouched — a
6701    // single lease taken once for the whole wave and released once it is
6702    // done is what stops a *different* borrower (another run's own wave, its
6703    // e2e/gate, a human's `magi review`) from interleaving a build into the
6704    // same directory while this one is in flight. Best-effort, not
6705    // all-or-nothing: a wave that cannot get the lease within its own
6706    // longest job's budget still runs — an hour of paid implementer calls is
6707    // not thrown away over cache contention — but every write-allowed seat
6708    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
6709    // below), the same fallback a read-only seat always gets, rather than
6710    // building into a directory this run was never granted. The identity
6711    // record is still invalidated below either way, so the next tracked
6712    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
6713    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6714    let wait_started = Instant::now();
6715    let cache_guard = if let Some(cache_dir) = cache {
6716        if jobs_had_a_writer {
6717            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6718            let budget = jobs
6719                .iter()
6720                .map(|j| j.timeout)
6721                .max()
6722                .unwrap_or(Duration::from_secs(60));
6723            acquire_cache_lease(state, cache_dir, &owner, budget, node)
6724                .await
6725                .ok()
6726        } else {
6727            None
6728        }
6729    } else {
6730        None
6731    };
6732    // Carved out of each job's own budget, not added on top of it: a seat
6733    // that waited behind the lease must not also get its full timeout
6734    // afterward, or a run contended on the cache could double the time it
6735    // spends per wave. `saturating_sub` floors at zero rather than
6736    // wrapping - a job whose whole budget was spent waiting starts with
6737    // none left, which is the honest number, not a free minimum.
6738    let waited_for_lease = wait_started.elapsed();
6739    let mut set = tokio::task::JoinSet::new();
6740    let overlay = prompts.overlay(node);
6741    for (i, mut job) in jobs.into_iter().enumerate() {
6742        job.timeout = job.timeout.saturating_sub(waited_for_lease);
6743        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6744        if cache.is_some() {
6745            job.prompt.push('\n');
6746            job.prompt
6747                .push_str(&prompt::build_cache_note(node, job.allow_write));
6748        }
6749        let sem = Arc::clone(&sem);
6750        let run = run.to_owned();
6751        let node = node.to_owned();
6752        // Only implementers were told about the task's attachments, so only
6753        // their seats get the directory widened for reading.
6754        let attachments = if node == "implement" {
6755            state.attachments.clone()
6756        } else {
6757            Vec::new()
6758        };
6759        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
6760        // `prompt::build_cache_note`'s doc for why setting it anyway is
6761        // exactly how a sandboxed reviewer's write refusal got reported as a
6762        // defect in the patch, not a property of its own seat. And a
6763        // write-allowed one is handed it only when the lease above was
6764        // actually acquired: a wave that could not get it (`cache_guard` is
6765        // `None`, see its own comment) must not send seats to build into a
6766        // directory this run does not hold - that is the exact concurrent,
6767        // unmanaged-write race this module exists to prevent, not something
6768        // "proceeding anyway" is allowed to reintroduce.
6769        let cache = cache
6770            .filter(|_| job.allow_write && cache_guard.is_some())
6771            .map(Path::to_path_buf);
6772        set.spawn(async move {
6773            let _permit = sem.acquire().await;
6774            let mut seat = job.seat;
6775            let out = agent::invoke(
6776                &job.spec,
6777                &mut seat,
6778                &Invocation {
6779                    cwd: &job.cwd,
6780                    prompt: &job.prompt,
6781                    timeout: job.timeout,
6782                    allow_write: job.allow_write,
6783                    sessions: job.sessions,
6784                    artifacts: &job.artifacts,
6785                    stem: &job.stem,
6786                    run: &run,
6787                    node: &node,
6788                    cache_dir: cache.as_deref(),
6789                    attachments: &attachments,
6790                    writable: &[],
6791                },
6792            )
6793            .await;
6794            let out = match out {
6795                Ok(o) if o.usable() => AgentOutcome::Ok(o),
6796                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6797                // Billed work the CLI failed to hand over is not an ordinary
6798                // failure, but its text is the CLI's raw error JSON, not an
6799                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
6800                // read it as one by forgetting to check. `usable()` is always
6801                // false here (dropped implies an empty response), so this has
6802                // to be checked before the catch-all `Failed` below or the
6803                // one shape this exists for is lost with the rest.
6804                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6805                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6806                Ok(o) => AgentOutcome::Failed(format!(
6807                    "exited with {:?} and no usable output",
6808                    o.exit_code
6809                )),
6810                Err(e) => AgentOutcome::Failed(e.to_string()),
6811            };
6812            (i, seat, out)
6813        });
6814    }
6815    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6816    while let Some(joined) = set.join_next().await {
6817        let (i, seat, out) = match joined {
6818            Ok(v) => v,
6819            // No seat to clear: a panicked task never reported which one it
6820            // was. The defensive sweep below this loop is what stops that
6821            // seat's `active` entry from surviving forever.
6822            Err(e) => {
6823                tracing::error!("agent task panicked: {e}");
6824                continue;
6825            }
6826        };
6827        state.seat_finished(&seat.key);
6828        record_jobs(state, node, round, &seat.key, &out);
6829        if let Err(e) = state.save() {
6830            tracing::warn!("could not persist a seat's completion: {e:#}");
6831        }
6832        if collected.len() <= i {
6833            collected.resize_with(i + 1, || None);
6834        }
6835        collected[i] = Some((i, seat, out));
6836    }
6837    // Belt-and-braces for the panic branch above: every seat this exact batch
6838    // started shares this `(node, attempt)` pair, and every seat that finished
6839    // normally already cleared itself, so anything left tagged with it here
6840    // can only be a panicked task's leftover. Cleared unconditionally rather
6841    // than left to read as still answering forever.
6842    if state
6843        .active
6844        .values()
6845        .any(|a| a.node == node && a.attempt == attempt)
6846    {
6847        state
6848            .active
6849            .retain(|_, a| !(a.node == node && a.attempt == attempt));
6850        if let Err(e) = state.save() {
6851            tracing::warn!("could not persist the end of a wave: {e:#}");
6852        }
6853    }
6854    // Whether or not the lease above was actually held, several worktrees
6855    // may just have built into the cache with nothing here able to name one
6856    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
6857    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
6858    // clean it might not have strictly needed; trusting a stale match would
6859    // cost it a wrong answer.
6860    if let Some(cache_dir) = cache
6861        && jobs_had_a_writer
6862    {
6863        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6864    }
6865    if let Some(guard) = cache_guard {
6866        guard.release();
6867    }
6868    collected.into_iter().flatten().collect()
6869}
6870
6871/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
6872/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
6873/// this is data collection, not the fix-specific completion contract in
6874/// [`Runner::continue_fix_report`], and applies regardless of which node
6875/// asked.
6876///
6877/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
6878/// evidence from; `Failed` does not, and correctly contributes nothing — a
6879/// timeout or crash is not itself evidence about a command the seat may have
6880/// started.
6881fn record_jobs(
6882    state: &mut RunState,
6883    node: &str,
6884    round: Option<usize>,
6885    seat: &str,
6886    out: &AgentOutcome,
6887) {
6888    let commands: &[agent::CommandEvidence] = match out {
6889        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6890        AgentOutcome::Failed(_) => &[],
6891    };
6892    let checked_at = Timestamp::now();
6893    for c in commands {
6894        state.jobs.push(JobRecord {
6895            node: node.to_owned(),
6896            round,
6897            seat: seat.to_owned(),
6898            id: c.id.clone(),
6899            description: c.description.clone(),
6900            checked_at,
6901            status: match c.exit_code {
6902                Some(0) => JobStatus::Completed,
6903                Some(_) => JobStatus::Failed,
6904                None => JobStatus::Unknown,
6905            },
6906            exit_code: c.exit_code,
6907            result_summary: c.result_summary.clone(),
6908            source: c.source.clone(),
6909        });
6910    }
6911}
6912
6913/// Is a review round clean, given how many reviewer seats answered against
6914/// how many the round expected?
6915///
6916/// A seat that never answered (timeout, crash, unparsable output) is not a
6917/// seat that read the patch and found nothing — treating it as such is
6918/// exactly the bug this function exists to close. Under the default `block`
6919/// policy a missing seat can never be clean; `warn` still requires the seats
6920/// that *did* answer to have found nothing blocking and verification to be
6921/// green.
6922///
6923/// `quota_missing` narrows that `block` default for exactly one cause of
6924/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
6925/// a session limit lifts by the very next round buys nothing — the seat is
6926/// asked again with the same quota — so once every missing seat is accounted
6927/// for by a quota loss (and at least one seat *did* answer, so a decision has
6928/// something to rest on) the round is decided on the panel that could answer,
6929/// same as `warn` would. A panel that lost every seat to quota is not
6930/// decided here: `answered == 0` falls through to the existing `block`
6931/// fallback so a fully collapsed panel still waits rather than landing on no
6932/// review at all.
6933fn round_is_clean(
6934    blocking: usize,
6935    e2e_ok: bool,
6936    answered: usize,
6937    expected: usize,
6938    quota_missing: usize,
6939    policy: IncompleteReviewPolicy,
6940) -> bool {
6941    if blocking != 0 || !e2e_ok {
6942        return false;
6943    }
6944    if answered == expected || policy == IncompleteReviewPolicy::Warn {
6945        return true;
6946    }
6947    answered > 0 && expected - answered <= quota_missing
6948}
6949
6950/// The review loop's own conclusion, derived entirely from its persisted
6951/// round records and the round budget that produced them — never from
6952/// `status`, so a reentry (or `gate`/`merge` reading it independently)
6953/// recomputes the identical answer regardless of what an earlier node in the
6954/// same walk, or a previous walk, did to `status`.
6955///
6956/// `None` while more rounds remain to try, including when review never ran
6957/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
6958/// gone clean, or the budget is spent, or the tree has stopped moving (see
6959/// [`STAGNANT_LIMIT`]), the answer is one of two things:
6960///
6961/// - An incomplete panel that raised nothing is missing input, not a
6962///   verified tree — never a hand-off candidate, whatever verification said
6963///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
6964/// - Otherwise, green e2e on the last round hands off (see
6965///   [`Runner::stop_reviewing`]); red e2e blocks.
6966///
6967/// A last round whose own verification is still `ResourceBlocked` — magi
6968/// itself never got a command to run, not evidence the patch is broken —
6969/// is neither: this returns `None` for it too, the same as "more rounds
6970/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
6971/// handling of that shape) instead of this cheap recomputation guessing a
6972/// verdict a real attempt never produced.
6973fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
6974    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
6975        return Some(RunStatus::Gating);
6976    }
6977    let last = reviews.last()?;
6978    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
6979    if reviews.len() < max_rounds && !stagnant {
6980        return None;
6981    }
6982    if last.incomplete() && last.blocking == 0 {
6983        return Some(RunStatus::Blocked);
6984    }
6985    if last.e2e_status() == E2eStatus::ResourceBlocked {
6986        return None;
6987    }
6988    Some(if last.e2e.iter().all(CommandOutcome::ok) {
6989        RunStatus::Gating
6990    } else {
6991        RunStatus::Blocked
6992    })
6993}
6994
6995/// How long a re-ask may take, given the budget the first attempt had.
6996///
6997/// A `nudged` retry is a request to restate an answer the seat has already
6998/// worked out: it carries no new work, so it does not deserve the original
6999/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
7000/// 133 seconds while a third sat for over ten minutes on a resumed session
7001/// holding 410 KB of prior output - and because the retry had inherited the
7002/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
7003/// judging round whose other seats were long finished.
7004///
7005/// A quarter of the budget, with a floor so that a deliberately short timeout
7006/// does not collapse to nothing. A retry that re-sends the whole prompt
7007/// (because the seat kept no context) is the original job again, and keeps the
7008/// original budget.
7009fn retry_budget(full: Duration, nudged: bool) -> Duration {
7010    if nudged {
7011        (full / 4).max(Duration::from_secs(120)).min(full)
7012    } else {
7013        full
7014    }
7015}
7016
7017/// Run a wave and parse each reply, re-asking the seats whose reply was
7018/// unusable.
7019///
7020/// The re-ask is a nudge rather than the whole prompt again when the seat still
7021/// holds its conversation, which is the difference between a cheap retry and
7022/// paying for the entire candidate set twice.
7023///
7024/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
7025/// successor in `roster` is handed to it instead of being re-asked: the
7026/// handover is the retry. A seat with no successor left (a single-agent
7027/// roster, the roster's tail) is nudged as before, up to `retries` times. So
7028/// the asks to one seat in one node number at most
7029/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
7030/// is asked once (a dropped stream is nudged first), and only the last agent
7031/// of the chain gets the `retries` same-agent nudges. Each roster agent is
7032/// tried at most once per seat, walking forward from the seat's own position and never wrapping
7033/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
7034/// other failure stops the chain when the previous agent failed the same way
7035/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
7036/// [`has_context`] is false and the job's own full prompt and full budget are
7037/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
7038/// intermediate ones are not losses) and is returned as a failure like any
7039/// other absent seat — the caller decides whether the panel still has a
7040/// quorum. An empty `roster` disables handover: failures are nudged as they
7041/// always were, and a quota is simply lost. A reply that fails to parse or
7042/// validate is the prompt's doing and is only ever nudged, never handed over.
7043///
7044/// The returned [`SeatState`] names the agent that answered (or tried last).
7045#[allow(clippy::too_many_arguments)]
7046async fn ask_json_wave<T>(
7047    jobs: Vec<SeatJob>,
7048    sem: Arc<Semaphore>,
7049    retries: usize,
7050    roster: &[AgentSpec],
7051    ctx: &WaveCtx<'_>,
7052    losses: &mut Vec<QuotaLoss>,
7053    state: &mut RunState,
7054    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7055) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7056where
7057    T: serde::de::DeserializeOwned + Send + 'static,
7058{
7059    ask_wave_with(
7060        jobs,
7061        sem,
7062        retries,
7063        roster,
7064        ctx,
7065        losses,
7066        state,
7067        &|text: &str| {
7068            let v = verdict::extract_json::<T>(text)?;
7069            validate(&v)?;
7070            Ok(v)
7071        },
7072    )
7073    .await
7074}
7075
7076/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
7077/// a node whose answer is prose (deliberation) shares the same handover,
7078/// failure classification, quota bookkeeping and bounds instead of a copy.
7079///
7080/// A seat handed to another roster agent is sent the job's `handover` prompt
7081/// (when it has one) rather than `prompt`: the new agent has no session, so a
7082/// resume-style prompt would be incomplete. That holds for the handover ask
7083/// and for every nudge to that agent whose `has_context` is false.
7084#[allow(clippy::too_many_arguments)]
7085async fn ask_wave_with<T>(
7086    jobs: Vec<SeatJob>,
7087    sem: Arc<Semaphore>,
7088    retries: usize,
7089    roster: &[AgentSpec],
7090    ctx: &WaveCtx<'_>,
7091    losses: &mut Vec<QuotaLoss>,
7092    state: &mut RunState,
7093    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7094) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7095where
7096    T: Send + 'static,
7097{
7098    let n = jobs.len();
7099    let originals: Vec<SeatJob> = jobs;
7100    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7101    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7102    // Nudges each seat's *current* agent has taken — 0 for a first-ask
7103    // answer, N once it has gone through N nudges. Read back once this
7104    // returns, so a caller building a history record (`ReviewRecord`) can
7105    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
7106    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
7107    // field's own doc.
7108    let mut nudges: Vec<usize> = vec![0; n];
7109    // The agent now occupying each seat, the ids it has already been through,
7110    // where in the roster the walk began, the class of the last failure, and
7111    // the stem word of a handover not yet asked (full prompt, full budget).
7112    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7113    let mut tried: Vec<BTreeSet<String>> = specs
7114        .iter()
7115        .map(|s| BTreeSet::from([s.id.clone()]))
7116        .collect();
7117    let starts: Vec<usize> = specs
7118        .iter()
7119        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7120        .collect();
7121    let carry = ctx.carry_seats && !roster.is_empty();
7122    // Carried across rounds: ids that failed the seat earlier, and how the
7123    // last failure went (so a repeat of it is not handed over again).
7124    let carried: Vec<BTreeSet<String>> = originals
7125        .iter()
7126        .map(|j| {
7127            state
7128                .seat_history
7129                .get(&j.seat.key)
7130                .filter(|_| carry)
7131                .map(|h| h.failed.clone())
7132                .unwrap_or_default()
7133        })
7134        .collect();
7135    let mut prev: Vec<Option<FailClass>> = originals
7136        .iter()
7137        .map(|j| {
7138            state
7139                .seat_history
7140                .get(&j.seat.key)
7141                .filter(|_| carry)
7142                .and_then(|h| h.last_fail.clone())
7143        })
7144        .collect();
7145    let next_agent = |i: usize, tried: &BTreeSet<String>| -> Option<AgentSpec> {
7146        if carry {
7147            next_for_seat(roster, starts[i], tried, &carried[i]).cloned()
7148        } else {
7149            next_untried_in_roster(roster, starts[i], tried).cloned()
7150        }
7151    };
7152    let mut fresh: Vec<Option<String>> = vec![None; n];
7153    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7154    let mut pending: Vec<usize> = (0..n).collect();
7155
7156    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
7157    // asks: an agent with a successor is asked once and handed over, and only
7158    // a seat with no successor spends `retries` nudges on the same agent. This
7159    // only guarantees the loop's own termination whatever those say.
7160    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7161    for round in 0..max_rounds {
7162        if pending.is_empty() {
7163            break;
7164        }
7165        let mut batch = Vec::with_capacity(pending.len());
7166        let mut renudged: Vec<&str> = Vec::new();
7167        for &i in &pending {
7168            let src = &originals[i];
7169            // A seat now held by another agent than the job named has no
7170            // session of its own: it gets the full-context prompt whenever
7171            // it is asked in full (the handover ask, a nudge it cannot
7172            // resume).
7173            let full: &str = match &src.handover {
7174                Some(h) if specs[i].id != src.spec.id => h,
7175                _ => &src.prompt,
7176            };
7177            // The prompt and the budget are one decision: a nudge restates
7178            // finished work, a re-sent prompt redoes it.
7179            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7180                (
7181                    full.to_owned(),
7182                    src.timeout,
7183                    format!("{}-{word}-{}", src.stem, specs[i].id),
7184                )
7185            } else if nudges[i] == 0 {
7186                (full.to_owned(), src.timeout, src.stem.clone())
7187            } else {
7188                renudged.push(src.seat.key.as_str());
7189                let why = done[i]
7190                    .as_ref()
7191                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
7192                    .unwrap_or_else(|| "no parsable answer".to_owned());
7193                let nudge = prompt::nudge(&why);
7194                let nudged = has_context(&specs[i], &seats[i], src.sessions);
7195                let prompt = if nudged {
7196                    nudge
7197                } else {
7198                    format!("{full}\n\n---\n\n{nudge}")
7199                };
7200                (
7201                    prompt,
7202                    retry_budget(src.timeout, nudged),
7203                    format!("{}-retry{}", src.stem, nudges[i]),
7204                )
7205            };
7206            batch.push(SeatJob {
7207                spec: specs[i].clone(),
7208                seat: seats[i].clone(),
7209                cwd: src.cwd.clone(),
7210                prompt,
7211                timeout,
7212                allow_write: src.allow_write,
7213                sessions: src.sessions,
7214                artifacts: src.artifacts.clone(),
7215                stem,
7216                handover: None,
7217            });
7218        }
7219
7220        if !renudged.is_empty() {
7221            state.event(
7222                ctx.node,
7223                format!("retry {round}: re-asking {}", renudged.join(", ")),
7224            );
7225        }
7226        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7227        let mut still = Vec::new();
7228        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7229            seats[i] = seat;
7230            let class = FailClass::of(&out);
7231            // A dropped stream is nudged first (the conversation is still
7232            // there to pick up); only a seat whose nudges are spent hands over.
7233            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7234                && nudges[i] < retries
7235                && !roster.is_empty();
7236            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7237                let next = should_hand_over(prev[i].as_ref(), &cur)
7238                    .then(|| next_agent(i, &tried[i]))
7239                    .flatten();
7240                if carry {
7241                    let h = state
7242                        .seat_history
7243                        .entry(originals[i].seat.key.clone())
7244                        .or_default();
7245                    h.failed.insert(specs[i].id.clone());
7246                    h.last_fail = Some(cur.clone());
7247                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7248                        h.last_ok = None;
7249                    }
7250                    // Saved before the next agent is asked, so a restart in
7251                    // between does not forget who failed.
7252                    if let Err(e) = state.save() {
7253                        tracing::warn!("could not persist a seat's failure history: {e:#}");
7254                    }
7255                }
7256                if let Some(next) = next {
7257                    record_handover(
7258                        state,
7259                        ctx.node,
7260                        &originals[i].seat.key,
7261                        &specs[i].id,
7262                        &next.id,
7263                        &cur,
7264                        &fail_reason(&out),
7265                    );
7266                    tried[i].insert(next.id.clone());
7267                    prev[i] = Some(cur.clone());
7268                    seats[i] =
7269                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7270                    specs[i] = next;
7271                    fresh[i] = Some(cur.stem_word().to_owned());
7272                    nudges[i] = 0;
7273                    done[i] = Some(Err(anyhow::anyhow!(
7274                        "handed over after: {}",
7275                        fail_reason(&out)
7276                    )));
7277                    still.push(i);
7278                    continue;
7279                }
7280            }
7281            if carry
7282                && !nudge_first
7283                && let Some(cur) = class.clone()
7284            {
7285                // The chain ended here (no successor, or a repeated failure).
7286                let h = state
7287                    .seat_history
7288                    .entry(originals[i].seat.key.clone())
7289                    .or_default();
7290                h.failed.insert(specs[i].id.clone());
7291                h.last_fail = Some(cur);
7292            }
7293            let parsed = match out {
7294                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7295                AgentOutcome::Quota(o) => {
7296                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7297                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7298                }
7299                // Not a parseable answer: the nudge loop re-asks it, which is
7300                // exactly what a dropped stream needs. Just don't hand its raw
7301                // error JSON to `extract_json`.
7302                AgentOutcome::Dropped(o) => {
7303                    let why = o
7304                        .dropped
7305                        .as_ref()
7306                        .map(|d| d.why.as_str())
7307                        .unwrap_or("the CLI ended the stream without delivering its answer");
7308                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7309                }
7310                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7311            };
7312            let quota = class == Some(FailClass::Quota);
7313            let failed = parsed.is_err();
7314            done[i] = Some(parsed);
7315            if carry && !failed {
7316                let h = state
7317                    .seat_history
7318                    .entry(originals[i].seat.key.clone())
7319                    .or_default();
7320                h.failed.remove(&specs[i].id);
7321                h.last_ok = Some(specs[i].id.clone());
7322                h.last_fail = None;
7323            }
7324            // Do not re-ask a rate-limited seat (quota) — a retry is known to
7325            // fail the same way; and never re-ask a seat that already parsed.
7326            // A failed agent that still has a successor is not re-asked
7327            // either: the handover was its remedy and has just been refused
7328            // (the chain stops on a repeated failure class). A seat with no
7329            // successor left (a single-agent roster, the roster's tail, or an
7330            // empty roster) keeps the same-agent nudge, bounded by `retries`.
7331            let agent_failure = class.is_some()
7332                && !nudge_first
7333                && !roster.is_empty()
7334                && next_agent(i, &tried[i]).is_some();
7335            if failed && !quota && !agent_failure && nudges[i] < retries {
7336                nudges[i] += 1;
7337                still.push(i);
7338            }
7339        }
7340        pending = still;
7341    }
7342
7343    // One loss per seat whose chain ended on a quota: the intermediate ones
7344    // were absorbed by a handover and are not losses.
7345    for (i, q) in last_quota.into_iter().enumerate() {
7346        if let Some(reset) = q {
7347            losses.push(QuotaLoss {
7348                seat: originals[i].seat.key.clone(),
7349                node: ctx.node.to_owned(),
7350                at: Timestamp::now(),
7351                reset,
7352            });
7353        }
7354    }
7355
7356    seats
7357        .into_iter()
7358        .zip(done)
7359        .zip(nudges)
7360        .map(|((seat, res), attempts)| {
7361            (
7362                seat,
7363                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7364                attempts,
7365            )
7366        })
7367        .collect()
7368}
7369
7370/// Acquire the shared build cache's lease, waiting out contention within
7371/// `budget` (never past it — see AGENTS.md's build-cache section on why an
7372/// unbounded wait is never acceptable).
7373///
7374/// A first, non-blocking check happens before ever waiting; if it finds the
7375/// lease busy, that fact is logged as a `verify` event *and* flushed with
7376/// [`RunState::save`] immediately — not only once the wait finally succeeds
7377/// or gives up — so a `magi show` run by a different process while this one
7378/// is still waiting reads a `run.json` that says so, rather than whatever it
7379/// looked like before the wait started. The same applies to the terminal
7380/// failure: logged and saved before this returns `Err`, so a caller that
7381/// could not get the lease at all still leaves a legible record of why.
7382async fn acquire_cache_lease(
7383    state: &mut RunState,
7384    cache_dir: &Path,
7385    owner: &crate::cache::Owner,
7386    budget: Duration,
7387    context: &str,
7388) -> Result<crate::cache::Guard> {
7389    let home = crate::run::home();
7390    let started = Instant::now();
7391    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7392        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7393        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7394        Err(e) => {
7395            state.event(
7396                "verify",
7397                format!("{context}: could not check the shared build cache: {e:#}"),
7398            );
7399            if let Err(e2) = state.save() {
7400                tracing::warn!("could not persist a cache-check failure: {e2:#}");
7401            }
7402            return Err(e);
7403        }
7404    };
7405    state.event(
7406        "verify",
7407        format!(
7408            "{context}: waiting for the shared build cache at {} ({})",
7409            cache_dir.display(),
7410            busy.describe()
7411        ),
7412    );
7413    if let Err(e) = state.save() {
7414        tracing::warn!("could not persist a cache wait: {e:#}");
7415    }
7416    let remaining = budget.saturating_sub(started.elapsed());
7417    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7418        Ok(g) => Ok(g),
7419        Err(e) => {
7420            state.event("verify", format!("{context}: {e:#}"));
7421            if let Err(e2) = state.save() {
7422                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7423            }
7424            Err(e)
7425        }
7426    }
7427}
7428
7429/// Run `body` — a verify command batch — while holding the shared build
7430/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
7431/// never interleave with another borrower's build against the same
7432/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
7433/// timeout, or a human's own `magi review`. See the `cache` module doc for
7434/// why this matters more than Cargo's own per-target locking covers — two
7435/// *different* worktrees building the same package name/version into one
7436/// cache directory is a staleness bug, not a lock contention one.
7437///
7438/// The wait for the lease is carved out of `budget`, never on top of it —
7439/// `body` is handed whatever is left, so a caller's own node timeout is the
7440/// only clock involved, exactly what AGENTS.md's build-cache section asks
7441/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
7442/// cache configured at all — this is a pass-through: `body` runs with the
7443/// full budget and nothing is leased.
7444///
7445/// A lease that cannot be acquired within `budget` is reported as a single
7446/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
7447/// verification — the same shape a spawn failure already takes in
7448/// [`run_commands`], so a caller need not special-case it.
7449#[allow(clippy::too_many_arguments)]
7450async fn with_cache_lease<'s, F, Fut>(
7451    state: &'s mut RunState,
7452    cache_dir: Option<&Path>,
7453    node: &str,
7454    seat: &str,
7455    worktree: &Path,
7456    head: &str,
7457    budget: Duration,
7458    context: &str,
7459    body: F,
7460) -> (Vec<CommandOutcome>, bool)
7461where
7462    F: FnOnce(&'s mut RunState, Duration) -> Fut,
7463    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7464{
7465    let Some(cache_dir) = cache_dir else {
7466        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7467        return (outcomes, retried);
7468    };
7469    let home = crate::run::home();
7470    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7471    let started = Instant::now();
7472    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7473        Ok(g) => g,
7474        Err(e) => {
7475            return (
7476                vec![CommandOutcome {
7477                    command: "(waiting for the shared build cache)".to_owned(),
7478                    code: None,
7479                    output_tail: e.to_string(),
7480                    duration_ms: started.elapsed().as_millis() as u64,
7481                    resource_blocked: true,
7482                }],
7483                false,
7484            );
7485        }
7486    };
7487    let identity = crate::cache::Identity::new(worktree, head);
7488    if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7489        // A failed freshness check means this process cannot vouch for what
7490        // is sitting in the cache right now - on Windows this is exactly the
7491        // "a stale test executable is still locked, `cargo clean -p` cannot
7492        // remove it" case the evidence log records. Running verify anyway
7493        // and reporting whatever it says would let a result nobody can trust
7494        // stand for the tree it claims to have checked; fail the step
7495        // instead of the patch.
7496        state.event(
7497            "verify",
7498            format!(
7499                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7500                worktree.display(),
7501                short(head)
7502            ),
7503        );
7504        guard.release();
7505        return (
7506            vec![CommandOutcome {
7507                command: "(confirming the shared build cache is fresh)".to_owned(),
7508                code: None,
7509                output_tail: e.to_string(),
7510                duration_ms: started.elapsed().as_millis() as u64,
7511                resource_blocked: true,
7512            }],
7513            false,
7514        );
7515    }
7516    let remaining = budget.saturating_sub(started.elapsed());
7517    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7518    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
7519    // `start_kill`); confirm it actually has before handing the directory to
7520    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
7521    // for what this can and cannot see.
7522    if !timed_out_pids.is_empty() {
7523        wait_for_timed_out_children_to_die(&timed_out_pids).await;
7524    }
7525    guard.release();
7526    (outcomes, retried)
7527}
7528
7529/// Poll `pids` — commands [`run_commands`] reports as still running when its
7530/// own timeout elapsed — until every one is confirmed gone, or
7531/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
7532///
7533/// Real confirmation where confirmation is possible, not a substitute for
7534/// full process-tree observation: a grandchild the timed-out process spawned
7535/// and that survives independently of it is invisible to a pid check the
7536/// same way it always was, and continuing to observe and collect *that*
7537/// stays a different piece of work with its own owner. This only narrows a
7538/// fixed blind wait into an actual check of the pids this process does know
7539/// about.
7540async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7541    wait_for_pids_with(
7542        pids,
7543        crate::proc::pid_alive,
7544        LEASE_RELEASE_POLL,
7545        LEASE_RELEASE_MAX_WAIT,
7546    )
7547    .await;
7548}
7549
7550/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
7551/// interval and ceiling supplied by the caller, so the polling *logic* -
7552/// returns as soon as every pid reports dead, gives up at the ceiling
7553/// otherwise - is testable on millisecond durations without asking the real
7554/// OS about a pid at all.
7555async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7556    pids: &[u32],
7557    alive: F,
7558    poll: Duration,
7559    max_wait: Duration,
7560) {
7561    let deadline = Instant::now() + max_wait;
7562    loop {
7563        if pids.iter().all(|&pid| !alive(pid)) {
7564            return;
7565        }
7566        if Instant::now() >= deadline {
7567            return;
7568        }
7569        tokio::time::sleep(poll).await;
7570    }
7571}
7572
7573/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
7574/// admission that it could not even get a verify command to run, as opposed
7575/// to evidence the command actually produced? A caller that would otherwise
7576/// read a resource-blocked outcome as a red command must check this first:
7577/// see [`Runner::gate`], which retries rather than records `Blocked` when
7578/// this is true.
7579fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7580    outcomes.iter().any(|o| o.resource_blocked)
7581}
7582
7583/// What [`Runner::gate_fix_round`] decided.
7584enum GateFix {
7585    /// The tree changed and `verify.e2e` is still green: run the gate again.
7586    Retry,
7587    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
7588    /// last failure stands and the run ends blocked.
7589    Stop,
7590    /// `verify.e2e` could not run after the fix (magi's own contention):
7591    /// decide nothing now, a later reentry retries.
7592    Defer,
7593}
7594
7595/// Is every red command in `outcomes` an ordinary failure the code could
7596/// explain: it ran, exited non-zero, and said something?
7597///
7598/// A timeout, a spawn failure and a killed process all leave `code` `None`;
7599/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
7600/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
7601/// what the command is stays the gate's business.
7602fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7603    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7604    red.peek().is_some()
7605        && red.all(|o| {
7606            !o.resource_blocked
7607                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7608                && !o.output_tail.trim().is_empty()
7609        })
7610}
7611
7612/// Describe one verify command's outcome for the event log, distinguishing a
7613/// build/link failure — the toolchain never produced a binary to run — from
7614/// an actual test failure, since only the latter is a verdict on the patch.
7615fn e2e_outcome_label(o: &CommandOutcome) -> String {
7616    if o.ok() {
7617        return "pass".to_owned();
7618    }
7619    let reason = if o.build_failed() {
7620        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7621    } else {
7622        format!("FAIL ({:?})", o.code)
7623    };
7624    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7625}
7626
7627/// Run `verify.e2e`, retrying once if the first attempt could not build or
7628/// link — a build/link failure is frequently a race against a shared
7629/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
7630/// `verify` event per command, tagged with `context` (normally `"round N"`)
7631/// so the two call sites that need this — the ordinary per-round leg in
7632/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
7633/// it will ever call a round green — read identically in the event log.
7634async fn run_e2e_with_retry(
7635    state: &mut RunState,
7636    shell: &[String],
7637    commands: &[String],
7638    worktree: &Path,
7639    timeout: Duration,
7640    context: &str,
7641) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7642    let (mut e2e, mut timed_out_pids) = run_commands(
7643        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7644    )
7645    .await;
7646    for o in &e2e {
7647        state.event(
7648            "verify",
7649            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7650        );
7651    }
7652    // A build/link failure is not a verdict on the patch — it is frequently a
7653    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
7654    // one retry before letting a red like that decide the round.
7655    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7656    if verify_retried {
7657        state.event(
7658            "verify",
7659            format!(
7660                "{context}: verify could not build/link, not a test result — retrying once \
7661                 before concluding"
7662            ),
7663        );
7664        let retried = run_commands(
7665            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7666        )
7667        .await;
7668        e2e = retried.0;
7669        // Both attempts' timeouts matter, not just the last one: the first
7670        // attempt's descendants may still be alive alongside the retry's.
7671        timed_out_pids.extend(retried.1);
7672        for o in &e2e {
7673            state.event(
7674                "verify",
7675                format!(
7676                    "{context}: retry `{}` -> {}",
7677                    o.command,
7678                    e2e_outcome_label(o)
7679                ),
7680            );
7681        }
7682    }
7683    (e2e, verify_retried, timed_out_pids)
7684}
7685
7686/// Run configured shell commands in `cwd`, in order. The second element is
7687/// the pid of every command that hit `timeout` and was still running when
7688/// this stopped waiting on it (best-effort: `None` when the platform did not
7689/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
7690/// that releases a shared resource afterward needs to know.
7691///
7692/// Records `task` into [`RunState::active`] at every command boundary
7693/// (`RunState::task_command`) and clears it once the whole list has run
7694/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
7695/// for minutes with no seat and no output of its own to show for it (see
7696/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
7697/// apart from "not yet run" without this), and this is the only place that
7698/// knows which command is running right now and how many are left. Three
7699/// saves per command — start, not per second — matching the same "only at a
7700/// boundary" rule [`wave`] already follows for seats.
7701#[allow(clippy::too_many_arguments)]
7702async fn run_commands(
7703    state: &mut RunState,
7704    node: &str,
7705    task: &str,
7706    attempt: usize,
7707    shell: &[String],
7708    commands: &[String],
7709    cwd: &Path,
7710    timeout: Duration,
7711) -> (Vec<CommandOutcome>, Vec<u32>) {
7712    if commands.is_empty() {
7713        // Nothing to mark as running and nothing to clear — an empty list
7714        // means "not configured", and touching `active` (or the disk) over
7715        // that would be a write for every round of a repo with no
7716        // `verify.e2e` / `verify.gate` commands at all.
7717        return (Vec::new(), Vec::new());
7718    }
7719    let mut out = Vec::new();
7720    let mut timed_out_pids = Vec::new();
7721    let total = commands.len();
7722    for (idx, command) in commands.iter().enumerate() {
7723        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7724        if let Err(e) = state.save() {
7725            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7726        }
7727        let started = Instant::now();
7728        let mut cmd = tokio::process::Command::new(&shell[0]);
7729        cmd.quiet();
7730        cmd.args(&shell[1..])
7731            .arg(command)
7732            .current_dir(cwd)
7733            .stdin(std::process::Stdio::null())
7734            .stdout(std::process::Stdio::piped())
7735            .stderr(std::process::Stdio::piped())
7736            .kill_on_drop(true);
7737        let spawned = cmd.spawn();
7738        let (code, body) = match spawned {
7739            Ok(child) => {
7740                // Captured before the child is consumed below: `kill_on_drop`
7741                // only *asks* the process to die when the timeout branch
7742                // drops it, and the pid is the only way anyone downstream can
7743                // later check whether that request actually took.
7744                let pid = child.id();
7745                match tokio::time::timeout(timeout, child.wait_with_output()).await {
7746                    Ok(Ok(o)) => {
7747                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7748                        body.push_str(&String::from_utf8_lossy(&o.stderr));
7749                        (o.status.code(), body)
7750                    }
7751                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
7752                    Err(_) => {
7753                        if let Some(pid) = pid {
7754                            timed_out_pids.push(pid);
7755                        }
7756                        (None, format!("timed out after {}s", timeout.as_secs()))
7757                    }
7758                }
7759            }
7760            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7761        };
7762        out.push(CommandOutcome {
7763            command: command.clone(),
7764            code,
7765            output_tail: tail(&body, OUTPUT_TAIL),
7766            duration_ms: started.elapsed().as_millis() as u64,
7767            resource_blocked: false,
7768        });
7769    }
7770    state.task_finished(task);
7771    if let Err(e) = state.save() {
7772        tracing::warn!("could not persist the end of {task}: {e:#}");
7773    }
7774    (out, timed_out_pids)
7775}
7776
7777/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
7778/// section (`report::run`) and in the `merge` event this node records — for
7779/// the operator to run by hand.
7780///
7781/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
7782/// branch whose ruleset forbids merge commits (GitHub's "must not contain
7783/// merge commits", or "require linear history") rejects the push a `--no-ff`
7784/// merge would produce, which is exactly the guidance this function replaces.
7785/// `message`'s first line becomes the squash commit's subject, matching the
7786/// note `report::run` prints alongside this command — see that function for
7787/// why an explicit subject is not optional there.
7788fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7789    let repo = repo.display();
7790    match style {
7791        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7792        MergeStyle::Squash => {
7793            // The subject sits inside double quotes, and a title an agent
7794            // wrote may carry the characters that break out of them.
7795            let subject = message
7796                .lines()
7797                .next()
7798                .unwrap_or(branch)
7799                .replace(['\\', '"', '$', '`'], "");
7800            format!(
7801                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7802            )
7803        }
7804        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7805    }
7806}
7807
7808/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
7809/// under the hood, rejects a `title` over 256 characters and the whole
7810/// command fails — no PR at all, for a run whose body was otherwise fine
7811/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
7812/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
7813/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
7814/// this bound. It is a margin, not a guarantee — a title packed
7815/// with multi-unit characters could still in principle land close to the
7816/// edge, but a real task title's occasional emoji or accented letter fits
7817/// comfortably inside it.
7818const PR_TITLE_MAX: usize = 240;
7819
7820/// A pull request title from the opening line of `text`, or `None` when that
7821/// line is not English (GitHub text is) or has no letters.
7822///
7823/// A line within `max` is kept as is. A longer one is cut at the end of its
7824/// first sentence when that falls inside `max`, else at a word boundary with a
7825/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
7826/// make every merely-truncated title look non-English). The language check
7827/// runs on the kept text before any mark is added, so only what GitHub will
7828/// show is judged: an English opening followed by non-ASCII far past the cut
7829/// still passes.
7830fn english_title(text: &str, max: usize) -> Option<String> {
7831    let line = queue::first_line(text)?;
7832    let chars: Vec<char> = line.chars().collect();
7833    let (kept, mark) = if chars.len() <= max {
7834        (line.to_owned(), "")
7835    } else if let Some(end) = sentence_end(&chars, max) {
7836        (chars[..end].iter().collect::<String>(), "")
7837    } else {
7838        let room = max.saturating_sub(3);
7839        // Cut at the last space inside the room; when the char just past the
7840        // room is a space the room already ends on a word.
7841        let cut = if chars[room].is_whitespace() {
7842            room
7843        } else {
7844            chars[..room]
7845                .iter()
7846                .rposition(|c| c.is_whitespace())
7847                .unwrap_or(room)
7848        };
7849        let head: String = chars[..cut].iter().collect();
7850        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
7851        (head.to_owned(), "...")
7852    };
7853    if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
7854        return None;
7855    }
7856    Some(format!("{kept}{mark}"))
7857}
7858
7859/// The char length of the first sentence of `chars` when it ends within `max`
7860/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
7861/// abbreviations so `e.g. foo` does not end a title early.
7862fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
7863    const MIN: usize = 20;
7864    for i in MIN..max.min(chars.len()) {
7865        if !matches!(chars[i], '.' | '!' | '?') {
7866            continue;
7867        }
7868        let Some(&next) = chars.get(i + 1) else {
7869            continue;
7870        };
7871        if !next.is_whitespace() {
7872            continue;
7873        }
7874        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
7875        if after.is_some_and(|c| c.is_ascii_lowercase()) {
7876            continue;
7877        }
7878        let word: String = chars[..i]
7879            .iter()
7880            .rev()
7881            .take_while(|c| !c.is_whitespace())
7882            .collect::<Vec<_>>()
7883            .into_iter()
7884            .rev()
7885            .collect();
7886        let word = word.to_ascii_lowercase();
7887        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
7888            continue;
7889        }
7890        let end = chars[..i]
7891            .iter()
7892            .rposition(|c| !c.is_whitespace())
7893            .map_or(i, |p| p + 1);
7894        return Some(end);
7895    }
7896    None
7897}
7898
7899/// What `merge = "pr"` (and the merge commit of the other modes) says about a
7900/// change: a title and a body describing what was *implemented*, not the task
7901/// that asked for it. A task reads as a request; a reader of the merged
7902/// history wants the change.
7903struct PrMessage {
7904    title: String,
7905    body: String,
7906}
7907
7908impl PrMessage {
7909    /// Title, blank line, body. The first line is the squash/merge commit
7910    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
7911    /// has to stay one sensible line.
7912    fn commit_message(&self) -> String {
7913        format!("{}\n\n{}", self.title, self.body)
7914    }
7915}
7916
7917/// The text after a leading `TITLE:` (any case) on `line`.
7918fn title_marker(line: &str) -> Option<&str> {
7919    let line = line.trim();
7920    let head = line.get(..6)?;
7921    head.eq_ignore_ascii_case("title:")
7922        .then(|| line[6..].trim())
7923}
7924
7925/// The implementer's own one-line title: the `TITLE:` line the implement
7926/// prompt asks for at the top of its SUMMARY. Candidate commits are all
7927/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
7928/// source, and a title that says as much is refused here too.
7929fn summary_title(summary: &str) -> Option<String> {
7930    let first = summary.lines().find(|l| !l.trim().is_empty())?;
7931    let raw = title_marker(first)?;
7932    if raw.is_empty() {
7933        return None;
7934    }
7935    let title = queue::title_from(raw, PR_TITLE_MAX);
7936    let lower = title.to_ascii_lowercase();
7937    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7938        return None;
7939    }
7940    Some(title)
7941}
7942
7943/// How `open_review`'s instruction begins; see [`landing_title`].
7944const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7945
7946/// Marker `open_review` gives a candidate that nothing in the roster wrote.
7947const EXISTING_BRANCH: &str = "(existing branch)";
7948
7949/// Does this run review work that already existed, rather than implement a
7950/// task? Runs recorded before `reviewed_commits` existed carry only the
7951/// candidate marker.
7952fn is_review_run(state: &RunState) -> bool {
7953    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
7954}
7955
7956/// The title of a review-only run: the subject of the oldest commit under
7957/// review. Later commits are usually fixups, and `instruction` is the review
7958/// prompt, which says nothing about the change. GitHub text is English, so a
7959/// non-ASCII or blank subject yields `None` and the caller's neutral title.
7960fn review_title(state: &RunState) -> Option<String> {
7961    english_subject(state.reviewed_commits.as_ref()?.first()?)
7962}
7963
7964/// `raw` as a pull request title, or `None` when it is blank, not English
7965/// (GitHub text is), or one of magi's own candidate commit subjects.
7966fn english_subject(raw: &str) -> Option<String> {
7967    let raw = raw.trim();
7968    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
7969        return None;
7970    }
7971    let title = queue::title_from(raw, PR_TITLE_MAX);
7972    let lower = title.to_ascii_lowercase();
7973    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7974        return None;
7975    }
7976    Some(title)
7977}
7978
7979/// What a review-only run's branch says about itself, read at the moment the
7980/// pull request is opened.
7981#[derive(Debug, Clone, PartialEq, Eq)]
7982struct BranchFacts {
7983    /// `(subject, body)` of each commit, oldest first.
7984    commits: Vec<(String, String)>,
7985    /// Trimmed `git diff --stat`.
7986    stat: String,
7987}
7988
7989/// Longest diff stat shown: this many file lines plus the summary line.
7990const STAT_FILE_LINES: usize = 25;
7991/// Cap on the commit list, well inside GitHub's 65536-character body limit.
7992const COMMITS_MAX_CHARS: usize = 20_000;
7993
7994/// Read the commits and diff stat of `base..branch`. `None` when git cannot
7995/// say or finds nothing, so the caller falls back to what the run recorded.
7996async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
7997    let commits = git::commit_log(repo, base, branch).await.ok()?;
7998    if commits.is_empty() {
7999        return None;
8000    }
8001    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8002    let lines: Vec<&str> = stat.lines().collect();
8003    let stat = if lines.len() > STAT_FILE_LINES + 1 {
8004        let omitted = lines.len() - 1 - STAT_FILE_LINES;
8005        let more = format!(" ... {omitted} more file(s)");
8006        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8007        kept.push(&more);
8008        kept.push(lines[lines.len() - 1]);
8009        kept.join("\n")
8010    } else {
8011        lines.join("\n")
8012    };
8013    Some(BranchFacts { commits, stat })
8014}
8015
8016/// Defang what would break the surrounding markdown: a closing `</details>`
8017/// and a code fence.
8018fn markdown_safe(text: &str) -> String {
8019    text.replace("</details>", "&lt;/details&gt;")
8020        .replace("\x60\x60\x60", "~~~")
8021}
8022
8023fn neutral_title(state: &RunState, winner: char) -> String {
8024    format!(
8025        "chore: land candidate {} of run {}",
8026        winner.to_ascii_uppercase(),
8027        state.id
8028    )
8029}
8030
8031/// The pull request title to hand to `land::merge_subject`. A review-only run
8032/// opened by an earlier build titled its pull request with the review prompt;
8033/// that title is dropped (empty, so the fallback applies) rather than landed.
8034/// Any other title, including an operator's rename, passes through untouched,
8035/// and so does every title of a run that implements a task.
8036pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8037    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8038        ""
8039    } else {
8040        pr_title
8041    }
8042}
8043
8044/// What the squash subject falls back to when the pull request title is empty
8045/// or candidate-shaped: for a review-only run the derived title, never the
8046/// review prompt held in `instruction`.
8047pub fn landing_subject_source(state: &RunState) -> String {
8048    if is_review_run(state) {
8049        let winner = state.candidates.first().map_or('A', |c| c.label);
8050        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8051    }
8052    state.instruction.clone()
8053}
8054
8055/// `summary` without its `TITLE:` line, which the pull request title already
8056/// carries.
8057fn summary_without_title(summary: &str) -> String {
8058    let mut lines = summary.trim().lines().peekable();
8059    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8060        lines.next();
8061    }
8062    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8063}
8064
8065/// The pull request title and body for the winning candidate.
8066///
8067/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
8068/// the task's own opening line via [`queue::title_from`] when there is none.
8069/// `state.instruction` can open with blank lines (`task_text` only rejects a
8070/// body that is blank *entirely*), which `title_from` skips.
8071///
8072/// Body: the implementer's summary and the fixer's notes, then — when the
8073/// winning review round was not clean — the findings still open and whatever
8074/// the fixer declined, so `merge = "pr"` hands the reader the same material
8075/// `magi show` does. The task follows inside a collapsed block, and the
8076/// footer repeats the run and candidate as plain tags for a reader holding
8077/// only the merged commit or the PR body.
8078#[cfg(test)]
8079fn pr_message(state: &RunState, winner: char) -> PrMessage {
8080    pr_message_with(state, winner, None)
8081}
8082
8083/// [`pr_message`] with what the branch of a review-only run says about itself.
8084/// `facts` is ignored for a run that implements a task.
8085fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8086    let summary = state
8087        .candidates
8088        .iter()
8089        .find(|c| c.label == winner)
8090        .map(|c| c.summary.as_str())
8091        .unwrap_or_default();
8092    // The fallback is the operator's own words and may not be English; GitHub
8093    // text always is, so a non-English task gets a neutral title instead.
8094    let review = is_review_run(state);
8095    let title = if review {
8096        facts
8097            .and_then(|f| english_subject(&f.commits.first()?.0))
8098            .or_else(|| review_title(state))
8099            .or_else(|| {
8100                state
8101                    .candidates
8102                    .iter()
8103                    .find(|c| c.label == winner)
8104                    .filter(|c| !c.branch.starts_with("magi/"))
8105                    .and_then(|c| english_subject(&c.branch))
8106            })
8107            .unwrap_or_else(|| neutral_title(state, winner))
8108    } else {
8109        summary_title(summary).unwrap_or_else(|| {
8110            english_title(&state.instruction, PR_TITLE_MAX)
8111                .unwrap_or_else(|| neutral_title(state, winner))
8112        })
8113    };
8114
8115    let mut body = String::new();
8116    let what = summary_without_title(summary);
8117    if !what.is_empty() {
8118        body.push_str("## Summary\n\n");
8119        body.push_str(&what);
8120        body.push_str("\n\n");
8121    }
8122
8123    // The last round is usually a clean verification pass with no fix of its
8124    // own, so every round's notes are read, not just the final one's.
8125    let notes: Vec<(usize, &str)> = state
8126        .reviews
8127        .iter()
8128        .filter_map(|r| {
8129            let n = r.fix.as_ref()?.notes.trim();
8130            (!n.is_empty()).then_some((r.round, n))
8131        })
8132        .collect();
8133    if !notes.is_empty() {
8134        body.push_str("## Review fixes\n\n");
8135        if let [(_, only)] = notes.as_slice() {
8136            body.push_str(only);
8137            body.push_str("\n\n");
8138        } else {
8139            for (round, n) in &notes {
8140                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8141            }
8142        }
8143    }
8144    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8145
8146    let open = state.open_findings();
8147    if !open.is_empty() {
8148        body.push_str("## Open review findings\n\n");
8149        for f in &open {
8150            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8151        }
8152        body.push('\n');
8153    }
8154
8155    if let Some(fix) = fix
8156        && !fix.rejected.is_empty()
8157    {
8158        body.push_str("## Declined by the fixer\n\n");
8159        for r in &fix.rejected {
8160            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8161        }
8162        body.push('\n');
8163    }
8164
8165    if review {
8166        // The review prompt is not the task; list what the branch carries.
8167        body.push_str("## Commits under review\n\n");
8168        if let Some(facts) = facts {
8169            let mut left = COMMITS_MAX_CHARS;
8170            for (i, (subject, text)) in facts.commits.iter().enumerate() {
8171                let mut entry = format!("- {}\n", markdown_safe(subject));
8172                for l in markdown_safe(text).lines() {
8173                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
8174                }
8175                if left == 0 {
8176                    body.push_str(&format!(
8177                        "- ... {} more commit(s)\n",
8178                        facts.commits.len() - i
8179                    ));
8180                    break;
8181                }
8182                if entry.len() > left {
8183                    // Even the first commit is cut: one huge body must not
8184                    // push the whole description past GitHub's limit.
8185                    let mut end = left;
8186                    while !entry.is_char_boundary(end) {
8187                        end -= 1;
8188                    }
8189                    entry.truncate(end);
8190                    entry.push_str("\n  ... (truncated)\n");
8191                    left = 0;
8192                } else {
8193                    left -= entry.len();
8194                }
8195                body.push_str(&entry);
8196            }
8197            if !facts.stat.trim().is_empty() {
8198                body.push_str(&format!(
8199                    "\n## Diff stat\n\n```\n{}\n```\n",
8200                    markdown_safe(facts.stat.trim())
8201                ));
8202            }
8203        } else {
8204            match &state.reviewed_commits {
8205                Some(subjects) => {
8206                    for s in subjects {
8207                        body.push_str(&format!("- {}\n", s.trim()));
8208                    }
8209                }
8210                None => {
8211                    // An older run kept only the prompt, with the commit list
8212                    // after its first paragraph.
8213                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8214                    body.push_str(rest.trim());
8215                    body.push('\n');
8216                }
8217            }
8218        }
8219    } else {
8220        let task = state.instruction.trim();
8221        let task = if task.is_empty() {
8222            "(empty task)"
8223        } else {
8224            task
8225        };
8226        body.push_str(&format!(
8227            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8228            task.replace("</details>", "&lt;/details&gt;")
8229        ));
8230    }
8231
8232    body.push_str(&format!(
8233        "\n---\nmagi:run/{} magi:candidate-{}\n",
8234        state.id,
8235        winner.to_ascii_lowercase()
8236    ));
8237
8238    // Prompts are advisory; this is the enforced half of the confidentiality
8239    // rule, and it covers the verbatim task in <details> too.
8240    let id = crate::scrub::Identity::current();
8241    PrMessage {
8242        title: crate::scrub::scrub(&title, &id),
8243        body: crate::scrub::scrub(&body, &id),
8244    }
8245}
8246
8247/// The task with what the repository says about the existing work it names
8248/// appended, so an implementer knows what it started from and what it must
8249/// not redo. Unchanged when the task names nothing.
8250fn seeded_instruction(state: &RunState) -> String {
8251    match refs::describe(&state.seeds) {
8252        Some(facts) => format!(
8253            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8254             Candidates start from the unmerged branch named above, when there \
8255             is one, and carry any unmerged commit named by sha as a \
8256             cherry-pick. Check that this is what the task meant before \
8257             building on it.",
8258            state.instruction
8259        ),
8260        None => state.instruction.clone(),
8261    }
8262}
8263
8264/// Does the winner have no commits ahead of the base it would land on?
8265/// Any failure to find out reads as "not empty": the merge then behaves as it
8266/// always did rather than refusing on a guess.
8267async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8268    let base = &state.base_branch;
8269    let mut against = base.clone();
8270    if mode == MergeMode::Pr {
8271        let remote = &state.config.merge.remote;
8272        let tracking = format!("{remote}/{base}");
8273        let fetched = git::fetch(repo, remote, base).await;
8274        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8275            against = tracking;
8276        }
8277    }
8278    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8279}
8280
8281/// Why nothing was opened for an empty winner, with what the task's own
8282/// references resolved to.
8283fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8284    let mut detail = format!(
8285        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8286         nothing to open a pull request for"
8287    );
8288    match refs::describe(&state.seeds) {
8289        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8290        None => detail.push_str(
8291            "\nThe task names no existing branch or commit; if it means to land work \
8292             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8293        ),
8294    }
8295    detail
8296}
8297
8298/// What the `Pr` merge does once it knows whether the branch already has an
8299/// open pull request.
8300#[derive(Debug, PartialEq, Eq)]
8301enum PrPlan {
8302    Create,
8303    Adopt { url: String, title: String },
8304    Stop(String),
8305}
8306
8307/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
8308/// a failed lookup -> stop with the real reason. Never guesses.
8309fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8310    match found {
8311        Ok(land::OpenPr::None) => PrPlan::Create,
8312        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8313        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8314            "several open pull requests exist for this branch, not picking one: {}",
8315            urls.join(" ")
8316        )),
8317        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8318    }
8319}
8320
8321/// `gh pr create`, returning the PR url.
8322async fn gh_pr_create(
8323    cwd: &Path,
8324    base: &str,
8325    head: &str,
8326    title: &str,
8327    body: &str,
8328) -> Result<String> {
8329    let out = tokio::process::Command::new("gh")
8330        .args([
8331            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8332        ])
8333        .current_dir(cwd)
8334        .quiet()
8335        .stdin(std::process::Stdio::null())
8336        .output()
8337        .await
8338        .context("spawn gh")?;
8339    if out.status.success() {
8340        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8341    } else {
8342        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8343    }
8344}
8345
8346/// Tear a run's worktrees and branches down.
8347///
8348/// `home` is where the updated `run.json` is saved (via
8349/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
8350/// a housekeeping pass already has its own honest `home` handed to it, and
8351/// falling through to the global here would write back through whichever
8352/// directory some other process or test pinned into that `OnceLock` first,
8353/// not the one the caller actually resolved its `runs` and `state` from.
8354pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8355    let repo = state.repo.clone();
8356    let root = state.worktree_root();
8357    let winner = state.tally.as_ref().map(|t| t.winner);
8358    let mut removed = Vec::new();
8359
8360    for i in 0..state.candidates.len() {
8361        let c = state.candidates[i].clone();
8362        let is_winner = Some(c.label) == winner;
8363        if is_winner && !drop_winner {
8364            continue;
8365        }
8366        if c.worktree.exists() {
8367            git::worktree_remove(&repo, &c.worktree).await.ok();
8368            removed.push(c.worktree.to_string_lossy().into_owned());
8369        }
8370        // A branch handed to a later run (and its pull request) is not this
8371        // run's to delete.
8372        let handed_over = state.released_branches.contains(&c.branch);
8373        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8374            git::branch_delete(&repo, &c.branch).await.ok();
8375            removed.push(c.branch.clone());
8376        }
8377        state.candidates[i].folded = true;
8378    }
8379
8380    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8381        let path = name.path();
8382        let keep = !drop_winner
8383            && winner.is_some_and(|w| {
8384                path.file_name()
8385                    .is_some_and(|n| n == format!("cand-{w}").as_str())
8386            });
8387        if keep {
8388            continue;
8389        }
8390        git::worktree_remove(&repo, &path).await.ok();
8391        removed.push(path.to_string_lossy().into_owned());
8392    }
8393
8394    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
8395    // judge worktrees, so once the loop above has cleared all of them out,
8396    // the parent is a bare directory nobody else was ever going to remove -
8397    // git only ever managed what was inside it. Left alone, one of these
8398    // accumulates per fully-folded run; the operator's own machine had 74.
8399    // `remove_if_empty` re-checks rather than assuming: a run whose winner
8400    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
8401    // so does anything a run never claimed that happens to share the bay.
8402    remove_if_empty(&root);
8403
8404    if state.enabled_worktree_config && drop_winner {
8405        // A release, not a raw disable: some sibling run in this repository
8406        // may still hold its own reference (see `git::acquire_worktree_config`),
8407        // and only the last release actually turns the setting back off.
8408        git::release_worktree_config(&repo).await.ok();
8409        state.enabled_worktree_config = false;
8410    }
8411    state.save_under(home)?;
8412    Ok(removed)
8413}
8414
8415/// Remove `dir` if it exists and has nothing in it.
8416///
8417/// Best-effort and silent by design: a directory that is not empty (a run
8418/// whose winner is still parked there, a stray file some other process left)
8419/// is exactly the case this must refuse, and a directory that is already gone
8420/// is not a failure worth reporting either. `std::fs::remove_dir` itself
8421/// already refuses a non-empty directory, so the emptiness check below is
8422/// belt, not suspenders - it is what keeps this from ever attempting the
8423/// removal in the case that matters, rather than trusting `remove_dir`'s
8424/// error path to have no side effects if it ever changed.
8425fn remove_if_empty(dir: &Path) {
8426    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8427        std::fs::remove_dir(dir).ok();
8428    }
8429}
8430
8431/// Severity of the worst open finding in the last review round, for reporting.
8432pub fn worst_open(state: &RunState) -> Option<Severity> {
8433    state
8434        .reviews
8435        .last()?
8436        .reviews
8437        .iter()
8438        .flat_map(|r| r.findings.iter())
8439        .map(|f| f.severity)
8440        .max()
8441}
8442
8443#[cfg(test)]
8444mod tests {
8445    #[test]
8446    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8447        let leaked = vec!["chore(deps): update a crate".to_owned()];
8448        let own = "fix(daemon): apply a chosen action";
8449        assert!(should_retitle("", own, &leaked));
8450        assert!(should_retitle(
8451            &format!("{REVIEW_PROMPT_OPENING} `x`"),
8452            own,
8453            &leaked
8454        ));
8455        assert!(should_retitle(
8456            "chore: land candidate A of run 1",
8457            own,
8458            &leaked
8459        ));
8460        assert!(should_retitle(
8461            "magi: candidate A (uncommitted work)",
8462            own,
8463            &leaked
8464        ));
8465        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8466        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8467        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8468    }
8469
8470    #[test]
8471    fn pr_merge_plan_creates_adopts_or_stops() {
8472        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8473        assert_eq!(
8474            pr_merge_plan(Ok(land::OpenPr::One {
8475                url: "u".into(),
8476                title: "t".into()
8477            })),
8478            PrPlan::Adopt {
8479                url: "u".into(),
8480                title: "t".into()
8481            }
8482        );
8483        let PrPlan::Stop(many) =
8484            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8485        else {
8486            panic!("many must stop");
8487        };
8488        assert!(many.contains('a') && many.contains('b'));
8489        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8490            panic!("a failed lookup must stop");
8491        };
8492        assert!(err.contains("bad token"));
8493    }
8494
8495    use super::*;
8496    use crate::run::GateStatus;
8497    use std::collections::BTreeMap;
8498    use std::time::Duration;
8499
8500    fn conductor() -> AgentSpec {
8501        AgentSpec {
8502            id: "conductor".to_owned(),
8503            kind: crate::config::AgentKind::Command,
8504            model: None,
8505            command: vec!["true".to_owned()],
8506            extra_args: Vec::new(),
8507            env: BTreeMap::new(),
8508            prompt_delivery: None,
8509        }
8510    }
8511
8512    fn spec(id: &str) -> AgentSpec {
8513        AgentSpec {
8514            id: id.to_owned(),
8515            kind: crate::config::AgentKind::Command,
8516            model: None,
8517            command: vec!["true".to_owned()],
8518            extra_args: Vec::new(),
8519            env: BTreeMap::new(),
8520            prompt_delivery: None,
8521        }
8522    }
8523
8524    fn ids(xs: &[&str]) -> BTreeSet<String> {
8525        xs.iter().map(|s| (*s).to_owned()).collect()
8526    }
8527
8528    #[test]
8529    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8530        let roster = [spec("a"), spec("b"), spec("c")];
8531        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8532        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8533    }
8534
8535    #[test]
8536    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8537        let roster = [spec("a"), spec("b"), spec("c")];
8538        let failed = ids(&["a", "b", "c"]);
8539        // Rescue looks at the whole roster, once per id, then runs out.
8540        let mut tried = ids(&["b"]);
8541        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8542        assert_eq!(first.id, "a");
8543        tried.insert(first.id.clone());
8544        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8545        assert_eq!(second.id, "c");
8546        tried.insert(second.id.clone());
8547        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8548    }
8549
8550    #[test]
8551    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8552        let roster = [spec("a"), spec("b")];
8553        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8554        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8555    }
8556
8557    #[test]
8558    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8559        let roster = [spec("a"), spec("b"), spec("c")];
8560        let h = SeatHistory {
8561            failed: ids(&["a"]),
8562            last_ok: Some("b".to_owned()),
8563            last_fail: None,
8564        };
8565        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8566        // A last answerer that left the roster, or later failed, is ignored.
8567        let gone = SeatHistory {
8568            last_ok: Some("zzz".to_owned()),
8569            ..h.clone()
8570        };
8571        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8572        let failed = SeatHistory {
8573            failed: ids(&["a", "b"]),
8574            last_ok: Some("b".to_owned()),
8575            last_fail: None,
8576        };
8577        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8578    }
8579
8580    #[test]
8581    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8582        let first = SeatState::new("review-1", "alpha", 7);
8583        let next = handover_seat("review-1", "gamma", 7);
8584        assert_ne!(first.claude_session, next.claude_session);
8585    }
8586
8587    #[test]
8588    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8589        let mut state = state_with_summary("x", "y");
8590        let a = handover_seat("review-1", "beta", state.next_seat_seed());
8591        let b = handover_seat("review-1", "beta", state.next_seat_seed());
8592        assert_ne!(a.claude_session, b.claude_session);
8593    }
8594
8595    #[test]
8596    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8597        let roster = [spec("a"), spec("b")];
8598        let h = SeatHistory {
8599            failed: ids(&["a", "b"]),
8600            ..SeatHistory::default()
8601        };
8602        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8603        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8604        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8605    }
8606
8607    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
8608    // fallback loop depends on to terminate: it must walk forward from the
8609    // seat's own position, never restart at the front of the roster, and it
8610    // must never hand back an id already tried, however many times that id
8611    // happens to appear.
8612
8613    #[test]
8614    fn failure_signature_ignores_numbers_and_paths() {
8615        assert_eq!(
8616            failure_signature("exited with Some(2) and no usable output"),
8617            failure_signature("exited with Some(137) and no usable output")
8618        );
8619        assert_eq!(
8620            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8621            failure_signature("cannot open /var/x.txt: denied")
8622        );
8623        assert_ne!(failure_signature("boom"), failure_signature("bang"));
8624    }
8625
8626    #[test]
8627    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8628        let other = FailClass::Other("x".into());
8629        assert!(should_hand_over(None, &FailClass::Quota));
8630        assert!(should_hand_over(Some(&other), &FailClass::Quota));
8631        assert!(should_hand_over(
8632            Some(&FailClass::Timeout),
8633            &FailClass::Timeout
8634        ));
8635        assert!(should_hand_over(None, &other));
8636        assert!(!should_hand_over(Some(&other), &other));
8637        assert!(should_hand_over(
8638            Some(&other),
8639            &FailClass::Other("y".into())
8640        ));
8641        // A quota or timeout in between ends the run of identical failures.
8642        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8643        assert!(should_hand_over(Some(&FailClass::Quota), &other));
8644    }
8645
8646    #[test]
8647    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8648        let a = SeatState::new("judge-1", "alpha", 7);
8649        let b = handover_seat("judge-1", "beta", 7);
8650        assert_ne!(a.claude_session, b.claude_session);
8651        assert_eq!(b.turns, 0);
8652    }
8653
8654    #[test]
8655    fn a_timeout_is_classified_apart_from_other_failures() {
8656        assert_eq!(
8657            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8658            Some(FailClass::Timeout)
8659        );
8660        assert!(matches!(
8661            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8662            Some(FailClass::Other(_))
8663        ));
8664    }
8665
8666    #[test]
8667    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8668        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8669        let tried = BTreeSet::from(["beta".to_owned()]);
8670        // beta sits at index 1; the next candidate is gamma, never alpha —
8671        // which is very likely a different candidate slot's own agent.
8672        let next = next_untried_in_roster(&roster, 1, &tried);
8673        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8674    }
8675
8676    #[test]
8677    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8678        let roster = vec![spec("alpha"), spec("beta")];
8679        let tried = BTreeSet::from(["beta".to_owned()]);
8680        // beta is the roster's last entry: nothing follows it, and alpha —
8681        // earlier in the roster, almost certainly a different candidate
8682        // slot's own agent — must not be reached by wrapping back to it.
8683        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8684    }
8685
8686    #[test]
8687    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8688        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8689        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8690        // beta (index 1) and gamma (index 2, the only entry after it) have
8691        // both been tried; alpha (index 0) never has, but it comes before
8692        // beta's own position, so there is nothing further for this seat.
8693        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8694    }
8695
8696    #[test]
8697    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8698        let roster = vec![spec("a"), spec("a"), spec("b")];
8699        let tried = BTreeSet::from(["a".to_owned()]);
8700        let next = next_untried_in_roster(&roster, 0, &tried);
8701        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8702    }
8703
8704    #[test]
8705    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8706        let roster = vec![spec("a"), spec("b")];
8707        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8708        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8709    }
8710
8711    #[test]
8712    fn remove_if_empty_only_ever_takes_a_bare_directory() {
8713        let dir = tempfile::tempdir().unwrap();
8714        let bay = dir.path().join("ffff");
8715
8716        // Not there yet: nothing to do, nothing to panic on.
8717        remove_if_empty(&bay);
8718        assert!(!bay.exists());
8719
8720        // Something still inside - the winner's worktree, or a stray file -
8721        // keeps the directory standing.
8722        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8723        remove_if_empty(&bay);
8724        assert!(bay.exists(), "non-empty directory must survive");
8725
8726        // Once the last entry is gone, so is the directory itself.
8727        std::fs::remove_dir(bay.join("cand-A")).unwrap();
8728        remove_if_empty(&bay);
8729        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8730    }
8731
8732    // `round_is_clean` is the exact decision this task fixed: a round with a
8733    // seat that never answered must not read the same as a round every seat
8734    // actually reviewed. These are deterministic and process-free by design —
8735    // the equivalent end-to-end check (a real reviewer timing out under a
8736    // live graph run) is a genuine race against wall-clock contention, and a
8737    // spawn slow enough to blow even a generous budget under a loaded test
8738    // run must not turn this specific regression check flaky.
8739
8740    #[test]
8741    fn a_full_panel_that_found_nothing_is_clean() {
8742        assert!(round_is_clean(
8743            0,
8744            true,
8745            2,
8746            2,
8747            0,
8748            IncompleteReviewPolicy::Block
8749        ));
8750    }
8751
8752    #[test]
8753    fn a_missing_seat_is_never_clean_under_the_default_policy() {
8754        assert!(!round_is_clean(
8755            0,
8756            true,
8757            1,
8758            2,
8759            0,
8760            IncompleteReviewPolicy::Block
8761        ));
8762    }
8763
8764    #[test]
8765    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8766        assert!(!round_is_clean(
8767            1,
8768            true,
8769            1,
8770            2,
8771            0,
8772            IncompleteReviewPolicy::Warn
8773        ));
8774    }
8775
8776    #[test]
8777    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8778        assert!(round_is_clean(
8779            0,
8780            true,
8781            1,
8782            2,
8783            0,
8784            IncompleteReviewPolicy::Warn
8785        ));
8786    }
8787
8788    #[test]
8789    fn a_full_panel_with_an_open_finding_is_not_clean() {
8790        assert!(!round_is_clean(
8791            1,
8792            true,
8793            2,
8794            2,
8795            0,
8796            IncompleteReviewPolicy::Block
8797        ));
8798    }
8799
8800    #[test]
8801    fn a_full_panel_with_a_red_e2e_is_not_clean() {
8802        assert!(!round_is_clean(
8803            0,
8804            false,
8805            2,
8806            2,
8807            0,
8808            IncompleteReviewPolicy::Block
8809        ));
8810    }
8811
8812    // The stall this task closes: under the default `block` policy, a seat
8813    // missing only because it was rate limited must not force a wait for a
8814    // session limit that will not lift by the next round. `round_is_clean`
8815    // is where that quorum carve-out lives; the review loop around it never
8816    // changes what a reviewer's vote or a finding's severity means.
8817
8818    #[test]
8819    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8820        // 1 of 2 answered, and the one missing was quota'd — the exact
8821        // "review-2 rate limited (quota)" shape from the field report.
8822        assert!(round_is_clean(
8823            0,
8824            true,
8825            1,
8826            2,
8827            1,
8828            IncompleteReviewPolicy::Block
8829        ));
8830    }
8831
8832    #[test]
8833    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8834        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
8835        // not a quota loss (`quota_missing` stays 0) — worth another try.
8836        assert!(!round_is_clean(
8837            0,
8838            true,
8839            1,
8840            2,
8841            0,
8842            IncompleteReviewPolicy::Block
8843        ));
8844    }
8845
8846    #[test]
8847    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8848        assert!(!round_is_clean(
8849            1,
8850            true,
8851            1,
8852            2,
8853            1,
8854            IncompleteReviewPolicy::Block
8855        ));
8856        assert!(!round_is_clean(
8857            0,
8858            false,
8859            1,
8860            2,
8861            1,
8862            IncompleteReviewPolicy::Block
8863        ));
8864    }
8865
8866    #[test]
8867    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8868        // Every seat quota'd, nobody answered: there is no panel to decide
8869        // on, so this must fall through to the existing block-and-retry
8870        // fallback rather than call an unreviewed patch clean.
8871        assert!(!round_is_clean(
8872            0,
8873            true,
8874            0,
8875            2,
8876            2,
8877            IncompleteReviewPolicy::Block
8878        ));
8879    }
8880
8881    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8882        CommandOutcome {
8883            command: "test".to_owned(),
8884            code,
8885            output_tail: String::new(),
8886            duration_ms: 0,
8887            resource_blocked,
8888        }
8889    }
8890
8891    #[test]
8892    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8893        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8894        assert!(
8895            !verify_inconclusive(&[outcome(Some(1), false)]),
8896            "an ordinary failure is still evidence about the patch"
8897        );
8898        assert!(verify_inconclusive(&[outcome(None, true)]));
8899        assert!(
8900            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8901            "one inconclusive outcome taints the whole batch"
8902        );
8903        assert!(!verify_inconclusive(&[]));
8904    }
8905
8906    #[tokio::test]
8907    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
8908        // Alive for the first two checks, then dead - confirms the loop
8909        // actually re-polls rather than deciding once and sleeping out the
8910        // ceiling regardless.
8911        let calls = std::sync::atomic::AtomicUsize::new(0);
8912        let started = Instant::now();
8913        wait_for_pids_with(
8914            &[123],
8915            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
8916            Duration::from_millis(5),
8917            Duration::from_secs(5),
8918        )
8919        .await;
8920        assert!(
8921            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
8922            "must keep checking rather than deciding on the first answer"
8923        );
8924        assert!(
8925            started.elapsed() < Duration::from_secs(1),
8926            "must return the moment it is confirmed dead, not wait out the ceiling"
8927        );
8928    }
8929
8930    #[tokio::test]
8931    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
8932        let started = Instant::now();
8933        wait_for_pids_with(
8934            &[123],
8935            |_| true, // never reports dead
8936            Duration::from_millis(5),
8937            Duration::from_millis(30),
8938        )
8939        .await;
8940        let elapsed = started.elapsed();
8941        assert!(
8942            elapsed >= Duration::from_millis(30),
8943            "must not give up before its own ceiling: {elapsed:?}"
8944        );
8945        assert!(
8946            elapsed < Duration::from_secs(1),
8947            "must not wait past its own ceiling either: {elapsed:?}"
8948        );
8949    }
8950
8951    #[tokio::test]
8952    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
8953        let started = Instant::now();
8954        wait_for_pids_with(
8955            &[],
8956            |_| true,
8957            Duration::from_secs(5),
8958            Duration::from_secs(5),
8959        )
8960        .await;
8961        assert!(
8962            started.elapsed() < Duration::from_millis(200),
8963            "an empty pid list has nothing to confirm"
8964        );
8965    }
8966
8967    // `review_conclusion` is the exact decision the review hand-off task
8968    // fixed: a round budget spent (or a tree that stopped moving) must not
8969    // collapse into `Blocked` regardless of what verification actually
8970    // said. Deterministic and process-free for the same reason the
8971    // `round_is_clean` family above is.
8972    fn review_round(
8973        clean: bool,
8974        blocking: usize,
8975        answered: usize,
8976        expected: usize,
8977        progressed: bool,
8978        e2e_ok: bool,
8979    ) -> ReviewRound {
8980        ReviewRound {
8981            round: 1,
8982            head: "h".to_owned(),
8983            verified_head: None,
8984            verified_at: None,
8985            reviews: Vec::new(),
8986            e2e: vec![CommandOutcome {
8987                command: "test".to_owned(),
8988                code: Some(if e2e_ok { 0 } else { 1 }),
8989                output_tail: String::new(),
8990                duration_ms: 0,
8991                resource_blocked: false,
8992            }],
8993            verify_retried: false,
8994            e2e_deferred: false,
8995            e2e_defer_reason: None,
8996            fix: None,
8997            blocking,
8998            answered,
8999            expected,
9000            clean,
9001            progressed,
9002            vote_split: false,
9003            reconsideration: Vec::new(),
9004            verdict: None,
9005        }
9006    }
9007
9008    #[test]
9009    fn review_conclusion_is_none_when_nothing_has_run() {
9010        assert_eq!(review_conclusion(&[], 3), None);
9011    }
9012
9013    #[test]
9014    fn review_conclusion_is_none_while_rounds_remain() {
9015        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9016        assert_eq!(review_conclusion(&rounds, 3), None);
9017    }
9018
9019    #[test]
9020    fn review_conclusion_is_gating_once_a_round_is_clean() {
9021        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9022        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9023    }
9024
9025    #[test]
9026    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9027        let rounds = vec![
9028            review_round(false, 1, 2, 2, true, true),
9029            review_round(false, 1, 2, 2, true, true),
9030        ];
9031        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9032    }
9033
9034    #[test]
9035    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9036        let rounds = vec![
9037            review_round(false, 1, 2, 2, true, true),
9038            review_round(false, 1, 2, 2, true, false),
9039        ];
9040        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9041    }
9042
9043    #[test]
9044    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9045        // Magi never got a command to run against this round's own head — a
9046        // resource-blocked attempt, not a red one — so this must never
9047        // settle on `Blocked` the way a genuine e2e failure would. `None`
9048        // here is what tells `Runner::review_loop` to retry the check
9049        // itself rather than trust this cheap recomputation with a verdict
9050        // it cannot actually produce.
9051        let mut blocked = review_round(false, 1, 2, 2, true, false);
9052        blocked.e2e[0].resource_blocked = true;
9053        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9054        assert_eq!(review_conclusion(&rounds, 2), None);
9055    }
9056
9057    #[test]
9058    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9059        // Missing input, not a verified tree — never a hand-off candidate.
9060        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9061        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9062    }
9063
9064    #[test]
9065    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9066        let rounds = vec![
9067            review_round(false, 1, 2, 2, false, true),
9068            review_round(false, 1, 2, 2, false, true),
9069        ];
9070        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9071    }
9072
9073    fn secs(n: u64) -> Duration {
9074        Duration::from_secs(n)
9075    }
9076
9077    /// A throwaway repo with one commit on `main`, for tests that need `merge`
9078    /// to make real (and, if it runs at all, real*ly fail*) git calls.
9079    fn init_repo(dir: &Path) {
9080        let run = |args: &[&str]| {
9081            let out = std::process::Command::new("git")
9082                .args(args)
9083                .current_dir(dir)
9084                .quiet()
9085                .output()
9086                .expect("spawn git");
9087            assert!(
9088                out.status.success(),
9089                "git {args:?} failed: {}",
9090                String::from_utf8_lossy(&out.stderr)
9091            );
9092        };
9093        run(&["init", "-b", "main"]);
9094        run(&["config", "user.name", "magi test"]);
9095        run(&["config", "user.email", "magi@example.com"]);
9096        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9097        run(&["add", "-A"]);
9098        run(&["commit", "-m", "init"]);
9099    }
9100
9101    // `settle_questions` is what closes the ghost the phone showed: a run's
9102    // seat asked something, the run then ended, and nothing was left to
9103    // abandon the question it left `open`. `HOME` is a process-wide
9104    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
9105    // first time it runs in the binary — every test below still reaches the
9106    // same directory whichever call won, and each gets its own run id from
9107    // `RunState::new`, so they never collide there.
9108    fn ask_test_home() {
9109        crate::run::pin_test_home();
9110    }
9111
9112    /// A minimal, git-free `Runner` at a given status — `settle_questions`
9113    /// reads nothing else off it.
9114    fn runner_at(status: RunStatus) -> Runner {
9115        let mut state = RunState::new(
9116            PathBuf::from("/nonexistent/repo"),
9117            "main".to_owned(),
9118            "deadbeef".to_owned(),
9119            "task".to_owned(),
9120            Config::default(),
9121        );
9122        state.status = status;
9123        Runner {
9124            state,
9125            roles: ResolvedRoles {
9126                implementers: Vec::new(),
9127                judges: Vec::new(),
9128                reviewers: Vec::new(),
9129                fixer: None,
9130                conductor: conductor(),
9131                implementer_roster: Vec::new(),
9132                judge_roster: Vec::new(),
9133                reviewer_roster: Vec::new(),
9134            },
9135            sem: Arc::new(Semaphore::new(1)),
9136            pause: Pause::new(),
9137            interrupt: Pause::new(),
9138        }
9139    }
9140
9141    /// `park_here` folding in the reason `Pause::park_because` recorded -
9142    /// this is what lets an operator reading a run's events tell an
9143    /// interrupt-driven park from an ordinary shutdown park.
9144    #[test]
9145    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9146        crate::run::pin_test_home();
9147        let mut runner = runner_at(RunStatus::Implementing);
9148        let interrupt = Pause::new();
9149        runner.watch_interrupt(interrupt.clone());
9150
9151        interrupt.park_because("task a1b2 asked to run first");
9152
9153        assert!(runner.park_here().expect("park_here"));
9154        assert!(runner.state.parked);
9155        let last = runner.state.events.last().expect("a park event");
9156        assert_eq!(last.node, "park");
9157        assert!(
9158            last.message.contains("task a1b2 asked to run first"),
9159            "expected the interrupt reason in {:?}",
9160            last.message
9161        );
9162    }
9163
9164    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
9165    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
9166    /// cleared) must not make a *different* run - one only watching its own,
9167    /// unshared interrupt `Pause` - see itself as parked. If a future change
9168    /// ever collapsed these back into one handle, the interrupt scheduler
9169    /// would park every run for the rest of the daemon's life, not just the
9170    /// one it meant to interrupt.
9171    #[test]
9172    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9173        crate::run::pin_test_home();
9174        let mut runner = runner_at(RunStatus::Implementing);
9175        let shutdown = Pause::new();
9176        runner.on_pause(shutdown.clone());
9177        let interrupt = Pause::new();
9178        runner.watch_interrupt(interrupt.clone());
9179
9180        // Nobody has asked for anything yet.
9181        assert!(!runner.park_here().expect("park_here"));
9182        assert!(!runner.state.parked);
9183
9184        // Only the interrupt handle fires; the shutdown handle stays clear.
9185        interrupt.park_because("test");
9186        assert!(!shutdown.parked());
9187        assert!(runner.park_here().expect("park_here"));
9188    }
9189
9190    /// The property every prior attempt at this feature failed to pin down:
9191    /// asking a run to park while one of its nodes has a real, in-flight
9192    /// async operation running (an agent call, in production) must not cut
9193    /// that operation short. `park_here` is only ever consulted *between*
9194    /// `execute`'s node calls - see its own doc - so nothing inside a node
9195    /// can observe a park request until the node itself returns. This proves
9196    /// that structurally, with real `tokio` concurrency and a channel
9197    /// handshake (never a sleep, which would only prove "usually", not
9198    /// "cannot"): the "node" below reports that it has genuinely started,
9199    /// and only then is the park requested; the node still has to be told to
9200    /// finish before `park_here` is ever called, exactly mirroring every
9201    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
9202    /// in `execute`.
9203    #[tokio::test]
9204    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9205        crate::run::pin_test_home();
9206        let mut runner = runner_at(RunStatus::Implementing);
9207        let interrupt = Pause::new();
9208        runner.watch_interrupt(interrupt.clone());
9209
9210        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9211        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9212
9213        // Stands in for one node's in-flight agent call: it proves it has
9214        // genuinely started, then blocks - exactly as a spawned CLI process
9215        // does - until told to finish.
9216        let node = async move {
9217            started_tx.send(()).expect("send started");
9218            finish_rx.await.expect("recv finish");
9219            "node finished"
9220        };
9221
9222        let interrupter = async move {
9223            started_rx.await.expect("recv started");
9224            // The call is now genuinely in flight. Ask it to park.
9225            interrupt.park_because("higher-priority task waiting");
9226            // Nothing the node does can observe this yet - there is no
9227            // check inside it, by construction - so let the executor run
9228            // anything pending and then let the node finish on its own.
9229            tokio::task::yield_now().await;
9230            finish_tx.send(()).expect("send finish");
9231        };
9232
9233        let (node_result, ()) = tokio::join!(node, interrupter);
9234        assert_eq!(
9235            node_result, "node finished",
9236            "the in-flight call ran to completion"
9237        );
9238
9239        // Only now, at the boundary the real `execute` would check right
9240        // after this node, does the park take effect.
9241        assert!(runner.park_here().expect("park_here"));
9242        assert!(runner.state.parked);
9243    }
9244
9245    /// A run parked mid-competition carries every field it had accumulated
9246    /// through the exact same disk round-trip an ordinary resume uses -
9247    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
9248    /// Nothing about parking for an interrupt is a special case of that path;
9249    /// this is what proves it rather than assuming it.
9250    #[test]
9251    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9252        crate::run::pin_test_home();
9253        let mut runner = runner_at(RunStatus::Judging);
9254        // `Runner::resume` re-resolves roles from the saved config, which
9255        // refuses an empty roster - give it the same minimal one `conductor`
9256        // itself uses.
9257        runner.state.config.agents = vec![conductor()];
9258        runner.state.candidates = vec![Candidate {
9259            index: 0,
9260            label: 'A',
9261            agent: "alpha".to_owned(),
9262            branch: "magi/x/A".to_owned(),
9263            worktree: PathBuf::from("/nonexistent/worktree"),
9264            summary: "did the thing".to_owned(),
9265            stat: "1 file changed".to_owned(),
9266            files: 1,
9267            commits: 1,
9268            empty: false,
9269            failed: None,
9270            verified_noop: None,
9271            duration_ms: 1234,
9272            folded: false,
9273        }];
9274        let run_id = runner.state.id.clone();
9275
9276        let interrupt = Pause::new();
9277        runner.watch_interrupt(interrupt.clone());
9278        interrupt.park_because("task c3d4 asked to run first");
9279        assert!(runner.park_here().expect("park_here"));
9280
9281        let resumed = Runner::resume(&run_id).expect("resume");
9282        assert_eq!(resumed.state.candidates.len(), 1);
9283        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9284        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9285        assert_eq!(resumed.state.status, runner.state.status);
9286        assert!(
9287            resumed.state.parked,
9288            "still parked until `execute` actually walks the graph again"
9289        );
9290        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9291    }
9292
9293    /// A fresh open question on `run`, stored and handed back for assertions.
9294    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9295        let mut q = ask::Question::new(
9296            run.to_owned(),
9297            "implement".to_owned(),
9298            "impl-A".to_owned(),
9299            "Which storage backend should the cache use?".to_owned(),
9300            String::new(),
9301            vec!["SQLite".to_owned(), "Redis".to_owned()],
9302        );
9303        store.put(&mut q).unwrap();
9304        q
9305    }
9306
9307    #[test]
9308    fn a_failed_runs_open_question_is_abandoned() {
9309        ask_test_home();
9310        let store = ask::Questions::open();
9311        let mut runner = runner_at(RunStatus::Failed);
9312        let run = runner.state.id.clone();
9313        let q = ask_open_question(&store, &run);
9314
9315        runner.settle_questions();
9316
9317        let back = store.get(&q.id).unwrap();
9318        assert!(
9319            !back.status.open(),
9320            "the seat that asked died with the run; nobody is left to read an answer"
9321        );
9322        assert!(
9323            back.detail.contains(&run) && back.detail.contains("failed"),
9324            "the reason names what the run became, not just that it is gone: {}",
9325            back.detail
9326        );
9327    }
9328
9329    #[test]
9330    fn a_merged_runs_open_question_is_abandoned_too() {
9331        ask_test_home();
9332        let store = ask::Questions::open();
9333        // A run that finishes cleanly still leaves nobody to read an answer -
9334        // this is not only a failure-path cleanup.
9335        for status in [RunStatus::Merged, RunStatus::Ready] {
9336            let mut runner = runner_at(status);
9337            let run = runner.state.id.clone();
9338            let q = ask_open_question(&store, &run);
9339
9340            runner.settle_questions();
9341
9342            let back = store.get(&q.id).unwrap();
9343            assert!(
9344                !back.status.open(),
9345                "{status:?} run's question must not outlive the run"
9346            );
9347        }
9348    }
9349
9350    #[test]
9351    fn a_still_resumable_runs_open_question_is_left_alone() {
9352        ask_test_home();
9353        let store = ask::Questions::open();
9354        // `Blocked` and `Stalled` can still be resumed — the candidates, the
9355        // review round and the seat sessions are all still on disk — so a
9356        // question asked mid-round may yet get a real answer from a real
9357        // resume. Sweeping it here would be exactly the failure mode this
9358        // whole feature exists to avoid on the other side.
9359        for status in [RunStatus::Blocked, RunStatus::Stalled] {
9360            let mut runner = runner_at(status);
9361            let run = runner.state.id.clone();
9362            let q = ask_open_question(&store, &run);
9363
9364            runner.settle_questions();
9365
9366            let back = store.get(&q.id).unwrap();
9367            assert!(
9368                back.status.open(),
9369                "{status:?} is still alive; the question must still be waiting"
9370            );
9371        }
9372    }
9373
9374    #[test]
9375    fn settle_questions_never_touches_an_already_answered_question() {
9376        ask_test_home();
9377        let store = ask::Questions::open();
9378        let mut runner = runner_at(RunStatus::Failed);
9379        let run = runner.state.id.clone();
9380        let mut q = ask_open_question(&store, &run);
9381        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9382            .unwrap();
9383        store.put(&mut q).unwrap();
9384
9385        // Called twice, the way a crash-recovered daemon reclaim and the
9386        // graph's own cleanup both can for the same run — `abandon_for_run`
9387        // only ever touches what is still open, so this must be inert both
9388        // times, not merely the second.
9389        runner.settle_questions();
9390        runner.settle_questions();
9391
9392        let back = store.get(&q.id).unwrap();
9393        assert_eq!(
9394            back.status,
9395            ask::QuestionStatus::Answered,
9396            "a real answer is a decision on record, never overwritten by a sweep"
9397        );
9398    }
9399
9400    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
9401    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
9402    /// without merging, whose winner is still the operator's answer to read.
9403    /// Nothing previously called `fold_run` itself with a real `tally`, so
9404    /// this is the first test to pin down the one distinction the whole
9405    /// automatic-fold feature depends on: the winner's worktree and branch
9406    /// must survive, everything else sharing the run's worktree bay - a
9407    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
9408    /// second sweep treats every non-winner directory under the bay alike -
9409    /// must not.
9410    #[tokio::test]
9411    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9412        crate::run::pin_test_home();
9413        let tmp = tempfile::tempdir().expect("tempdir");
9414        let repo = tmp.path().join("repo");
9415        std::fs::create_dir_all(&repo).unwrap();
9416        init_repo(&repo);
9417
9418        let mut config = Config::default();
9419        config.graph.worktree_root = Some(tmp.path().join("wt"));
9420
9421        let mut state = RunState::new(
9422            repo.clone(),
9423            "main".to_owned(),
9424            "deadbeef".to_owned(),
9425            "task".to_owned(),
9426            config,
9427        );
9428        let root = state.worktree_root();
9429        let wt_a = root.join("cand-A");
9430        let wt_b = root.join("cand-B");
9431        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9432            .await
9433            .expect("worktree A");
9434        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9435            .await
9436            .expect("worktree B");
9437
9438        state.candidates = vec![
9439            Candidate {
9440                index: 0,
9441                label: 'A',
9442                agent: "alpha".to_owned(),
9443                branch: "magi/x/A".to_owned(),
9444                worktree: wt_a.clone(),
9445                summary: String::new(),
9446                stat: String::new(),
9447                files: 0,
9448                commits: 0,
9449                empty: false,
9450                failed: None,
9451                verified_noop: None,
9452                duration_ms: 0,
9453                folded: false,
9454            },
9455            Candidate {
9456                index: 1,
9457                label: 'B',
9458                agent: "beta".to_owned(),
9459                branch: "magi/x/B".to_owned(),
9460                worktree: wt_b.clone(),
9461                summary: String::new(),
9462                stat: String::new(),
9463                files: 0,
9464                commits: 0,
9465                empty: false,
9466                failed: None,
9467                verified_noop: None,
9468                duration_ms: 0,
9469                folded: false,
9470            },
9471        ];
9472        state.tally = Some(Tally {
9473            first_choice: BTreeMap::from([('A', 1)]),
9474            borda: BTreeMap::new(),
9475            winner: 'A',
9476            rankings: 1,
9477            unanimous_initial: true,
9478            deliberated: false,
9479            changed_votes: 0,
9480            unanimous_final: true,
9481            tie_break: None,
9482            judges: 1,
9483            present: 1,
9484            quorum: 1,
9485            met_quorum: true,
9486            uncontested: None,
9487        });
9488        state.status = RunStatus::Ready;
9489
9490        fold_run(&mut state, false, &crate::run::home())
9491            .await
9492            .expect("fold_run");
9493
9494        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9495        assert!(
9496            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9497            "the unmerged winner's branch survives"
9498        );
9499        assert!(
9500            !state.candidates[0].folded,
9501            "the winner is not marked folded"
9502        );
9503
9504        assert!(!wt_b.exists(), "the loser's worktree is removed");
9505        assert!(
9506            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9507            "the loser's branch is removed"
9508        );
9509        assert!(state.candidates[1].folded, "the loser is marked folded");
9510    }
9511
9512    /// A branch handed to a later run is that run's (and its pull request's):
9513    /// folding the run that released it must not delete it.
9514    #[tokio::test]
9515    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9516        let tmp = tempfile::tempdir().expect("tempdir");
9517        let repo = tmp.path().join("repo");
9518        std::fs::create_dir_all(&repo).unwrap();
9519        init_repo(&repo);
9520        let home = tmp.path().join("home");
9521
9522        let mut config = Config::default();
9523        config.graph.worktree_root = Some(tmp.path().join("wt"));
9524        let mut state = RunState::new(
9525            repo.clone(),
9526            "main".to_owned(),
9527            "deadbeef".to_owned(),
9528            "task".to_owned(),
9529            config,
9530        );
9531        // The worktree is already gone (released); the branch survives.
9532        git::git(&repo, &["branch", "magi/x/A", "main"])
9533            .await
9534            .expect("branch");
9535        state.candidates = vec![Candidate {
9536            index: 0,
9537            label: 'A',
9538            agent: "alpha".to_owned(),
9539            branch: "magi/x/A".to_owned(),
9540            worktree: state.worktree_root().join("cand-A"),
9541            summary: String::new(),
9542            stat: String::new(),
9543            files: 0,
9544            commits: 0,
9545            empty: false,
9546            failed: None,
9547            verified_noop: None,
9548            duration_ms: 0,
9549            folded: true,
9550        }];
9551        state.released_to = Some("20260901-000000-new1".to_owned());
9552        state.released_branches = vec!["magi/x/A".to_owned()];
9553
9554        fold_run(&mut state, true, &home).await.expect("fold_run");
9555
9556        assert!(
9557            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9558            "the handed-over branch survives a fold"
9559        );
9560    }
9561
9562    /// A winner with nothing ahead of the base is caught before `gh` is ever
9563    /// asked for a pull request, and the message carries what the task's
9564    /// references resolved to.
9565    #[tokio::test]
9566    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9567        let tmp = tempfile::tempdir().expect("tempdir");
9568        let repo = tmp.path().join("repo");
9569        std::fs::create_dir_all(&repo).unwrap();
9570        init_repo(&repo);
9571        let run = |args: &[&str]| {
9572            let out = std::process::Command::new("git")
9573                .quiet()
9574                .args(args)
9575                .current_dir(&repo)
9576                .output()
9577                .expect("spawn git");
9578            assert!(out.status.success(), "git {args:?}");
9579        };
9580        run(&["branch", "magi/x/A"]);
9581        run(&["checkout", "-q", "-b", "magi/x/B"]);
9582        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9583        run(&["add", "-A"]);
9584        run(&["commit", "-q", "-m", "work"]);
9585        run(&["checkout", "-q", "main"]);
9586
9587        let mut state = RunState::new(
9588            repo.clone(),
9589            "main".to_owned(),
9590            "deadbeef".to_owned(),
9591            "task".to_owned(),
9592            Config::default(),
9593        );
9594        state.seeds = vec![refs::Seed {
9595            token: "magi/27b2/A".to_owned(),
9596            kind: refs::SeedKind::Unresolved,
9597            sha: String::new(),
9598            branch: true,
9599            detail: "no branch or commit named magi/27b2/A".to_owned(),
9600        }];
9601
9602        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9603        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9604        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9605        let detail = empty_candidate_detail(&state, "main");
9606        assert!(detail.starts_with("empty candidate"), "{detail}");
9607        assert!(detail.contains("magi/27b2/A"), "{detail}");
9608    }
9609
9610    /// `status == Ready` used to be read as "this is the harmless
9611    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
9612    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
9613    /// run's PR was closed without merging — and reentering `merge` with
9614    /// `mode` still `Pr` does not know the difference, so it pushed and
9615    /// opened a second pull request. `mode == Local` reproduces the same
9616    /// blind spot without a network call: reentry must not attempt another
9617    /// git merge once this node has already recorded an outcome.
9618    #[tokio::test]
9619    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9620        let tmp = tempfile::tempdir().expect("tempdir");
9621        let repo = tmp.path().join("repo");
9622        std::fs::create_dir_all(&repo).unwrap();
9623        init_repo(&repo);
9624
9625        let mut config = Config::default();
9626        config.merge.mode = MergeMode::Local;
9627
9628        let mut state = RunState::new(
9629            repo.clone(),
9630            "main".to_owned(),
9631            "deadbeef".to_owned(),
9632            "task".to_owned(),
9633            config,
9634        );
9635        state.candidates = vec![Candidate {
9636            index: 0,
9637            label: 'A',
9638            agent: "alpha".to_owned(),
9639            branch: "does-not-exist".to_owned(),
9640            worktree: repo.clone(),
9641            summary: String::new(),
9642            stat: String::new(),
9643            files: 0,
9644            commits: 0,
9645            empty: false,
9646            failed: None,
9647            verified_noop: None,
9648            duration_ms: 0,
9649            folded: false,
9650        }];
9651        state.tally = Some(Tally {
9652            first_choice: BTreeMap::from([('A', 1)]),
9653            borda: BTreeMap::new(),
9654            winner: 'A',
9655            rankings: 1,
9656            unanimous_initial: true,
9657            deliberated: false,
9658            changed_votes: 0,
9659            unanimous_final: true,
9660            tie_break: None,
9661            judges: 0,
9662            present: 0,
9663            quorum: 0,
9664            met_quorum: true,
9665            uncontested: Some("only candidate A produced a change".to_owned()),
9666        });
9667        state.reviews = vec![ReviewRound {
9668            round: 1,
9669            head: "deadbeef".to_owned(),
9670            verified_head: None,
9671            verified_at: None,
9672            reviews: Vec::new(),
9673            e2e: Vec::new(),
9674            fix: None,
9675            blocking: 0,
9676            answered: 0,
9677            expected: 0,
9678            clean: true,
9679            verify_retried: false,
9680            e2e_deferred: false,
9681            e2e_defer_reason: None,
9682            progressed: false,
9683            vote_split: false,
9684            reconsideration: Vec::new(),
9685            verdict: None,
9686        }];
9687        state.gate = vec![CommandOutcome {
9688            command: "test".to_owned(),
9689            code: Some(0),
9690            output_tail: String::new(),
9691            duration_ms: 0,
9692            resource_blocked: false,
9693        }];
9694        state.gate_ran = true;
9695        // Reached its conclusion already — e.g. `land` closing the PR without
9696        // merging it, which (like the honest `MergeMode::None` path) leaves
9697        // `status` at `Ready`. The recorded outcome is what actually marks
9698        // this node done.
9699        state.status = RunStatus::Ready;
9700        state.merge = Some(MergeOutcome {
9701            mode: MergeMode::Local,
9702            ok: false,
9703            detail: "already concluded".to_owned(),
9704            empty: false,
9705        });
9706
9707        let mut runner = Runner {
9708            state,
9709            roles: ResolvedRoles {
9710                implementers: Vec::new(),
9711                judges: Vec::new(),
9712                reviewers: Vec::new(),
9713                fixer: None,
9714                conductor: conductor(),
9715                implementer_roster: Vec::new(),
9716                judge_roster: Vec::new(),
9717                reviewer_roster: Vec::new(),
9718            },
9719            sem: Arc::new(Semaphore::new(1)),
9720            pause: Pause::new(),
9721            interrupt: Pause::new(),
9722        };
9723
9724        runner.merge().await.expect("merge");
9725
9726        assert_eq!(
9727            runner.state.status,
9728            RunStatus::Ready,
9729            "a concluded run's status must not change on reentry"
9730        );
9731        assert_eq!(
9732            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9733            Some("already concluded"),
9734            "merge must not run again once the node already recorded an outcome"
9735        );
9736    }
9737
9738    /// `gate` leaves `state.gate_ran` false both before it has ever run and
9739    /// when its last attempt was resource-blocked (the shared build cache
9740    /// could not be acquired or confirmed fresh in time - see
9741    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
9742    /// `Vec` this also leaves behind used to read as "nothing failed" and let
9743    /// a run merge a tree the gate never actually checked - exactly the case
9744    /// a contended cache produces on every retry until it clears. `merge`
9745    /// must refuse until `gate` has actually recorded an attempt.
9746    #[tokio::test]
9747    async fn merge_refuses_a_gate_that_has_not_actually_run() {
9748        let tmp = tempfile::tempdir().expect("tempdir");
9749        let repo = tmp.path().join("repo");
9750        std::fs::create_dir_all(&repo).unwrap();
9751        init_repo(&repo);
9752
9753        let mut config = Config::default();
9754        config.merge.mode = MergeMode::Local;
9755
9756        let mut state = RunState::new(
9757            repo.clone(),
9758            "main".to_owned(),
9759            "deadbeef".to_owned(),
9760            "task".to_owned(),
9761            config,
9762        );
9763        state.candidates = vec![Candidate {
9764            index: 0,
9765            label: 'A',
9766            agent: "alpha".to_owned(),
9767            branch: "does-not-exist".to_owned(),
9768            worktree: repo.clone(),
9769            summary: String::new(),
9770            stat: String::new(),
9771            files: 0,
9772            commits: 0,
9773            empty: false,
9774            failed: None,
9775            verified_noop: None,
9776            duration_ms: 0,
9777            folded: false,
9778        }];
9779        state.tally = Some(Tally {
9780            first_choice: BTreeMap::from([('A', 1)]),
9781            borda: BTreeMap::new(),
9782            winner: 'A',
9783            rankings: 1,
9784            unanimous_initial: true,
9785            deliberated: false,
9786            changed_votes: 0,
9787            unanimous_final: true,
9788            tie_break: None,
9789            judges: 0,
9790            present: 0,
9791            quorum: 0,
9792            met_quorum: true,
9793            uncontested: Some("only candidate A produced a change".to_owned()),
9794        });
9795        state.reviews = vec![ReviewRound {
9796            round: 1,
9797            head: "deadbeef".to_owned(),
9798            verified_head: None,
9799            verified_at: None,
9800            reviews: Vec::new(),
9801            e2e: Vec::new(),
9802            fix: None,
9803            blocking: 0,
9804            answered: 0,
9805            expected: 0,
9806            clean: true,
9807            verify_retried: false,
9808            e2e_deferred: false,
9809            e2e_defer_reason: None,
9810            progressed: false,
9811            vote_split: false,
9812            reconsideration: Vec::new(),
9813            verdict: None,
9814        }];
9815        // The point: `gate` has not recorded anything yet.
9816        state.gate = Vec::new();
9817        state.gate_ran = false;
9818        state.status = RunStatus::Gating;
9819
9820        let mut runner = Runner {
9821            state,
9822            roles: ResolvedRoles {
9823                implementers: Vec::new(),
9824                judges: Vec::new(),
9825                reviewers: Vec::new(),
9826                fixer: None,
9827                conductor: conductor(),
9828                implementer_roster: Vec::new(),
9829                judge_roster: Vec::new(),
9830                reviewer_roster: Vec::new(),
9831            },
9832            sem: Arc::new(Semaphore::new(1)),
9833            pause: Pause::new(),
9834            interrupt: Pause::new(),
9835        };
9836
9837        runner.merge().await.expect("merge");
9838
9839        assert!(
9840            runner.state.merge.is_none(),
9841            "an empty gate must never be read as a passing one: {:?}",
9842            runner.state.merge
9843        );
9844    }
9845
9846    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
9847    /// commands configured and `merge.mode` is `none` (a review-only run).
9848    /// `gate` must still record a real attempt — zero commands, vacuously
9849    /// passed — rather than leaving `state.gate` empty in a way `merge`
9850    /// cannot tell apart from "never ran"; otherwise the run reaches
9851    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
9852    #[tokio::test]
9853    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9854        let tmp = tempfile::tempdir().expect("tempdir");
9855        let repo = tmp.path().join("repo");
9856        std::fs::create_dir_all(&repo).unwrap();
9857        init_repo(&repo);
9858
9859        // Default config: `verify.gate` empty, `merge.mode` is `none`.
9860        let config = Config::default();
9861
9862        let mut state = RunState::new(
9863            repo.clone(),
9864            "main".to_owned(),
9865            "deadbeef".to_owned(),
9866            "task".to_owned(),
9867            config,
9868        );
9869        state.candidates = vec![Candidate {
9870            index: 0,
9871            label: 'A',
9872            agent: "alpha".to_owned(),
9873            branch: "does-not-exist".to_owned(),
9874            worktree: repo.clone(),
9875            summary: String::new(),
9876            stat: String::new(),
9877            files: 0,
9878            commits: 0,
9879            empty: false,
9880            failed: None,
9881            verified_noop: None,
9882            duration_ms: 0,
9883            folded: false,
9884        }];
9885        state.tally = Some(Tally {
9886            first_choice: BTreeMap::from([('A', 1)]),
9887            borda: BTreeMap::new(),
9888            winner: 'A',
9889            rankings: 1,
9890            unanimous_initial: true,
9891            deliberated: false,
9892            changed_votes: 0,
9893            unanimous_final: true,
9894            tie_break: None,
9895            judges: 0,
9896            present: 0,
9897            quorum: 0,
9898            met_quorum: true,
9899            uncontested: Some("only candidate A produced a change".to_owned()),
9900        });
9901        state.reviews = vec![ReviewRound {
9902            round: 1,
9903            head: "deadbeef".to_owned(),
9904            verified_head: None,
9905            verified_at: None,
9906            reviews: Vec::new(),
9907            e2e: Vec::new(),
9908            fix: None,
9909            blocking: 0,
9910            answered: 0,
9911            expected: 0,
9912            clean: true,
9913            verify_retried: false,
9914            e2e_deferred: false,
9915            e2e_defer_reason: None,
9916            progressed: false,
9917            vote_split: false,
9918            reconsideration: Vec::new(),
9919            verdict: None,
9920        }];
9921
9922        let mut runner = Runner {
9923            state,
9924            roles: ResolvedRoles {
9925                implementers: Vec::new(),
9926                judges: Vec::new(),
9927                reviewers: Vec::new(),
9928                fixer: None,
9929                conductor: conductor(),
9930                implementer_roster: Vec::new(),
9931                judge_roster: Vec::new(),
9932                reviewer_roster: Vec::new(),
9933            },
9934            sem: Arc::new(Semaphore::new(1)),
9935            pause: Pause::new(),
9936            interrupt: Pause::new(),
9937        };
9938
9939        runner.gate().await.expect("gate");
9940        assert!(
9941            runner.state.gate_ran,
9942            "zero configured commands is still a real attempt, not an unrun gate"
9943        );
9944        assert!(runner.state.gate.is_empty());
9945        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
9946        assert_ne!(
9947            runner.state.status,
9948            RunStatus::Blocked,
9949            "a gate with nothing to check must not read as failed"
9950        );
9951
9952        runner.merge().await.expect("merge");
9953        assert_eq!(
9954            runner.state.status,
9955            RunStatus::Ready,
9956            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
9957        );
9958    }
9959
9960    /// `Config::cache_dir` is derived from `verify.e2e` as well as
9961    /// `verify.gate` (so the e2e leg and the final gate never build against
9962    /// different directories). With zero `verify.gate` commands but a
9963    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
9964    /// that lease before discovering it had nothing to run - so a repo with
9965    /// no gate commands could come back `resource_blocked` (and therefore
9966    /// still `gate_ran == false`) on nothing but an unrelated run holding the
9967    /// cache, exactly the contention this run's own zero commands could
9968    /// never have touched. `gate` must recognise there is nothing to check
9969    /// before it ever asks for the lease.
9970    #[tokio::test]
9971    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
9972        crate::run::pin_test_home();
9973        let home = crate::run::home();
9974
9975        let tmp = tempfile::tempdir().expect("tempdir");
9976        let repo = tmp.path().join("repo");
9977        std::fs::create_dir_all(&repo).unwrap();
9978        init_repo(&repo);
9979        // Unique to this test, so holding its lease cannot collide with
9980        // another test sharing the same process-wide `home`.
9981        let cache_dir = tmp.path().join("target");
9982
9983        let mut config = Config::default();
9984        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
9985        // `verify.gate` stays empty (the default). Bounded so a regression
9986        // that does start waiting fails the test in seconds, not hangs it.
9987        config.graph.timeout_verify = Some(2);
9988
9989        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
9990        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
9991            .expect("no io error acquiring directly")
9992        {
9993            crate::cache::AcquireOutcome::Acquired(g) => g,
9994            crate::cache::AcquireOutcome::Busy(b) => {
9995                panic!("expected the direct acquire to win the lease first: {b:?}")
9996            }
9997        };
9998
9999        let mut state = RunState::new(
10000            repo.clone(),
10001            "main".to_owned(),
10002            "deadbeef".to_owned(),
10003            "task".to_owned(),
10004            config,
10005        );
10006        state.candidates = vec![Candidate {
10007            index: 0,
10008            label: 'A',
10009            agent: "alpha".to_owned(),
10010            branch: "does-not-exist".to_owned(),
10011            worktree: repo.clone(),
10012            summary: String::new(),
10013            stat: String::new(),
10014            files: 0,
10015            commits: 0,
10016            empty: false,
10017            failed: None,
10018            verified_noop: None,
10019            duration_ms: 0,
10020            folded: false,
10021        }];
10022        state.tally = Some(Tally {
10023            first_choice: BTreeMap::from([('A', 1)]),
10024            borda: BTreeMap::new(),
10025            winner: 'A',
10026            rankings: 1,
10027            unanimous_initial: true,
10028            deliberated: false,
10029            changed_votes: 0,
10030            unanimous_final: true,
10031            tie_break: None,
10032            judges: 0,
10033            present: 0,
10034            quorum: 0,
10035            met_quorum: true,
10036            uncontested: Some("only candidate A produced a change".to_owned()),
10037        });
10038        state.reviews = vec![ReviewRound {
10039            round: 1,
10040            head: "deadbeef".to_owned(),
10041            verified_head: None,
10042            verified_at: None,
10043            reviews: Vec::new(),
10044            e2e: Vec::new(),
10045            fix: None,
10046            blocking: 0,
10047            answered: 0,
10048            expected: 0,
10049            clean: true,
10050            verify_retried: false,
10051            e2e_deferred: false,
10052            e2e_defer_reason: None,
10053            progressed: false,
10054            vote_split: false,
10055            reconsideration: Vec::new(),
10056            verdict: None,
10057        }];
10058
10059        let mut runner = Runner {
10060            state,
10061            roles: ResolvedRoles {
10062                implementers: Vec::new(),
10063                judges: Vec::new(),
10064                reviewers: Vec::new(),
10065                fixer: None,
10066                conductor: conductor(),
10067                implementer_roster: Vec::new(),
10068                judge_roster: Vec::new(),
10069                reviewer_roster: Vec::new(),
10070            },
10071            sem: Arc::new(Semaphore::new(1)),
10072            pause: Pause::new(),
10073            interrupt: Pause::new(),
10074        };
10075
10076        let started = std::time::Instant::now();
10077        runner.gate().await.expect("gate");
10078        assert!(
10079            started.elapsed() < Duration::from_secs(1),
10080            "a gate with nothing to run must never wait on a lease it never needed"
10081        );
10082        assert!(
10083            runner.state.gate_ran,
10084            "zero commands is still a real, immediate attempt"
10085        );
10086        assert!(runner.state.gate.is_empty());
10087        assert_ne!(
10088            runner.state.status,
10089            RunStatus::Blocked,
10090            "must not read as resource-blocked on a lease it never asked for"
10091        );
10092    }
10093
10094    /// The addendum's second gap: a `verify.gate` command running for real
10095    /// wall-clock time had nothing at all to show for it in `active` before
10096    /// `run_commands` learned to record it — a run could sit in `Gating` for
10097    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
10098    /// what was actually happening. Proven with a genuinely still-running
10099    /// command, not just a before/after check on the final state: a poller
10100    /// task reads the same `run.json` `gate()` is writing, the same way the
10101    /// phone or `magi show` would, while the shell command is still blocked
10102    /// on its own release marker.
10103    #[tokio::test]
10104    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10105        crate::run::pin_test_home();
10106
10107        let tmp = tempfile::tempdir().expect("tempdir");
10108        let repo = tmp.path().join("repo");
10109        std::fs::create_dir_all(&repo).unwrap();
10110        init_repo(&repo);
10111
10112        let mut config = Config::default();
10113        config.verify.gate = vec![
10114            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10115             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10116                .to_owned(),
10117        ];
10118
10119        let mut state = RunState::new(
10120            repo.clone(),
10121            "main".to_owned(),
10122            "deadbeef".to_owned(),
10123            "task".to_owned(),
10124            config,
10125        );
10126        let run_id = state.id.clone();
10127        state.candidates = vec![Candidate {
10128            index: 0,
10129            label: 'A',
10130            agent: "alpha".to_owned(),
10131            branch: "does-not-exist".to_owned(),
10132            worktree: repo.clone(),
10133            summary: String::new(),
10134            stat: String::new(),
10135            files: 0,
10136            commits: 0,
10137            empty: false,
10138            failed: None,
10139            verified_noop: None,
10140            duration_ms: 0,
10141            folded: false,
10142        }];
10143        state.tally = Some(Tally {
10144            first_choice: BTreeMap::from([('A', 1)]),
10145            borda: BTreeMap::new(),
10146            winner: 'A',
10147            rankings: 1,
10148            unanimous_initial: true,
10149            deliberated: false,
10150            changed_votes: 0,
10151            unanimous_final: true,
10152            tie_break: None,
10153            judges: 0,
10154            present: 0,
10155            quorum: 0,
10156            met_quorum: true,
10157            uncontested: Some("only candidate A produced a change".to_owned()),
10158        });
10159        state.reviews = vec![ReviewRound {
10160            round: 1,
10161            head: "deadbeef".to_owned(),
10162            verified_head: None,
10163            verified_at: None,
10164            reviews: Vec::new(),
10165            e2e: Vec::new(),
10166            fix: None,
10167            blocking: 0,
10168            answered: 0,
10169            expected: 0,
10170            clean: true,
10171            verify_retried: false,
10172            e2e_deferred: false,
10173            e2e_defer_reason: None,
10174            progressed: false,
10175            vote_split: false,
10176            reconsideration: Vec::new(),
10177            verdict: None,
10178        }];
10179
10180        let mut runner = Runner {
10181            state,
10182            roles: ResolvedRoles {
10183                implementers: Vec::new(),
10184                judges: Vec::new(),
10185                reviewers: Vec::new(),
10186                fixer: None,
10187                conductor: conductor(),
10188                implementer_roster: Vec::new(),
10189                judge_roster: Vec::new(),
10190                reviewer_roster: Vec::new(),
10191            },
10192            sem: Arc::new(Semaphore::new(1)),
10193            pause: Pause::new(),
10194            interrupt: Pause::new(),
10195        };
10196
10197        let started_marker = repo.join("started.marker");
10198        let release_marker = repo.join("release.marker");
10199        let poller = tokio::spawn(async move {
10200            // Bounded so a regression that never records the task entry
10201            // fails this test in seconds instead of hanging the suite —
10202            // the same shape `a_park_requested_while_a_seat_is_mid_call_
10203            // does_not_cut_it_short` uses for the same reason.
10204            for _ in 0..100 {
10205                if started_marker.exists()
10206                    && let Ok(s) = crate::run::RunState::load(&run_id)
10207                    && let Some(a) = s.active.get("gate")
10208                {
10209                    std::fs::write(&release_marker, b"go").expect("release marker");
10210                    return Some(a.clone());
10211                }
10212                tokio::time::sleep(Duration::from_millis(50)).await;
10213            }
10214            None
10215        });
10216
10217        runner.gate().await.expect("gate");
10218        let captured = poller.await.expect("poller task");
10219        let captured = captured.expect(
10220            "the poller never saw a `gate` task entry in run.json while the command was \
10221             still blocked on its own release marker",
10222        );
10223
10224        assert_eq!(captured.task.as_deref(), Some("gate"));
10225        assert_eq!(captured.node, "gate");
10226        assert_eq!(captured.index, Some(1));
10227        assert_eq!(captured.total, Some(1));
10228        assert!(
10229            captured
10230                .command
10231                .as_deref()
10232                .is_some_and(|c| c.contains("started.marker")),
10233            "{captured:?}"
10234        );
10235
10236        assert!(
10237            runner.state.active.is_empty(),
10238            "the entry must be cleared once the command actually finished: {:?}",
10239            runner.state.active
10240        );
10241        assert!(runner.state.gate_ran);
10242        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10243    }
10244
10245    /// The hand-off over a blocking finding a reviewer rejected on leaves a
10246    /// record for `land`; one with only a Minor, or no reject, leaves none.
10247    #[tokio::test]
10248    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10249        use crate::verdict::{Finding, ReviewVote, Severity};
10250        crate::run::pin_test_home();
10251        let tmp = tempfile::tempdir().expect("tempdir");
10252        let repo = tmp.path().join("repo");
10253        std::fs::create_dir_all(&repo).unwrap();
10254        init_repo(&repo);
10255
10256        for (severity, vote, expect) in [
10257            (Severity::Major, ReviewVote::Reject, true),
10258            (Severity::Minor, ReviewVote::Reject, false),
10259            (Severity::Major, ReviewVote::Approve, false),
10260        ] {
10261            let mut round = review_round(false, 1, 1, 1, false, true);
10262            round.reviews = vec![ReviewRecord {
10263                reviewer: 1,
10264                agent: "alpha".to_owned(),
10265                summary: String::new(),
10266                findings: vec![Finding {
10267                    id: "R1-1-1".to_owned(),
10268                    severity,
10269                    file: None,
10270                    line: None,
10271                    title: "t".to_owned(),
10272                    detail: String::new(),
10273                }],
10274                vote: Some(vote),
10275                failed: None,
10276                duration_ms: 0,
10277                attempts: 0,
10278            }];
10279            let mut state = RunState::new(
10280                repo.clone(),
10281                "main".to_owned(),
10282                "deadbeef".to_owned(),
10283                "task".to_owned(),
10284                Config::default(),
10285            );
10286            state.reviews = vec![round];
10287            let mut runner = Runner {
10288                state,
10289                roles: ResolvedRoles {
10290                    implementers: Vec::new(),
10291                    judges: Vec::new(),
10292                    reviewers: Vec::new(),
10293                    fixer: None,
10294                    conductor: conductor(),
10295                    implementer_roster: Vec::new(),
10296                    judge_roster: Vec::new(),
10297                    reviewer_roster: Vec::new(),
10298                },
10299                sem: Arc::new(Semaphore::new(1)),
10300                pause: Pause::new(),
10301                interrupt: Pause::new(),
10302            };
10303            let shell = runner.state.config.shell();
10304            runner
10305                .stop_reviewing("round budget spent", &shell, &repo)
10306                .await
10307                .expect("stop_reviewing");
10308            assert_eq!(runner.state.status, RunStatus::Gating);
10309            assert_eq!(
10310                runner.state.contested_handoff.is_some(),
10311                expect,
10312                "{severity:?} + {vote:?}"
10313            );
10314        }
10315    }
10316
10317    /// The shape the incident this whole fix responds to actually had: the
10318    /// round budget spent, the last round's own e2e blocked on the shared
10319    /// build cache (held here by a live pid — this test process — exactly
10320    /// `cache`'s own unit tests' pattern for "another owner, still alive"
10321    /// without forking a process). `stop_reviewing` must retry it — not
10322    /// silently leave the round looking untouched (the catch-up-only half of
10323    /// the bug), and not read the contention as a red `e2e` and block the
10324    /// run on it (the other half). Called directly, the same way
10325    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
10326    /// above exercises `gate`, so this never needs a real cargo build to
10327    /// reach: the lease is never released, so `with_cache_lease` never gets
10328    /// past acquiring it into anything that would need a real workspace.
10329    #[tokio::test]
10330    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10331        crate::run::pin_test_home();
10332        let home = crate::run::home();
10333
10334        let tmp = tempfile::tempdir().expect("tempdir");
10335        let repo = tmp.path().join("repo");
10336        std::fs::create_dir_all(&repo).unwrap();
10337        init_repo(&repo);
10338        let head = crate::git::rev_parse(&repo, "HEAD")
10339            .await
10340            .expect("rev-parse");
10341        // Unique to this test, so holding its lease cannot collide with
10342        // another test sharing the same process-wide `home`.
10343        let cache_dir = tmp.path().join("target");
10344
10345        let mut config = Config::default();
10346        config.verify.e2e = vec![format!(
10347            "CARGO_TARGET_DIR='{}' test -f README.md",
10348            cache_dir.display()
10349        )];
10350        config.graph.review_rounds = 1;
10351        // Bounded so a regression that does start waiting fails the test in
10352        // seconds, not hangs it.
10353        config.graph.timeout_verify = Some(2);
10354
10355        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10356        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10357            .expect("no io error acquiring directly")
10358        {
10359            crate::cache::AcquireOutcome::Acquired(g) => g,
10360            crate::cache::AcquireOutcome::Busy(b) => {
10361                panic!("expected the direct acquire to win the lease first: {b:?}")
10362            }
10363        };
10364
10365        let mut state = RunState::new(
10366            repo.clone(),
10367            "main".to_owned(),
10368            head.clone(),
10369            "task".to_owned(),
10370            config,
10371        );
10372        state.candidates = vec![Candidate {
10373            index: 0,
10374            label: 'A',
10375            agent: "alpha".to_owned(),
10376            branch: "does-not-exist".to_owned(),
10377            worktree: repo.clone(),
10378            summary: String::new(),
10379            stat: String::new(),
10380            files: 0,
10381            commits: 0,
10382            empty: false,
10383            failed: None,
10384            verified_noop: None,
10385            duration_ms: 0,
10386            folded: false,
10387        }];
10388        state.tally = Some(Tally {
10389            first_choice: BTreeMap::from([('A', 1)]),
10390            borda: BTreeMap::new(),
10391            winner: 'A',
10392            rankings: 1,
10393            unanimous_initial: true,
10394            deliberated: false,
10395            changed_votes: 0,
10396            unanimous_final: true,
10397            tie_break: None,
10398            judges: 0,
10399            present: 0,
10400            quorum: 0,
10401            met_quorum: true,
10402            uncontested: Some("only candidate A produced a change".to_owned()),
10403        });
10404        // The round budget's last round, deferred: `needs_catchup_run`'s
10405        // other trigger. `stop_reviewing`'s retry machinery must treat this
10406        // exactly like a resource-blocked attempt once it actually runs.
10407        state.reviews = vec![ReviewRound {
10408            round: 1,
10409            head: head.clone(),
10410            verified_head: None,
10411            verified_at: None,
10412            reviews: Vec::new(),
10413            e2e: Vec::new(),
10414            fix: None,
10415            blocking: 1,
10416            answered: 1,
10417            expected: 1,
10418            clean: false,
10419            verify_retried: false,
10420            e2e_deferred: true,
10421            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10422            progressed: false,
10423            vote_split: false,
10424            reconsideration: Vec::new(),
10425            verdict: None,
10426        }];
10427
10428        let mut runner = Runner {
10429            state,
10430            roles: ResolvedRoles {
10431                implementers: Vec::new(),
10432                judges: Vec::new(),
10433                reviewers: Vec::new(),
10434                fixer: None,
10435                conductor: conductor(),
10436                implementer_roster: Vec::new(),
10437                judge_roster: Vec::new(),
10438                reviewer_roster: Vec::new(),
10439            },
10440            sem: Arc::new(Semaphore::new(1)),
10441            pause: Pause::new(),
10442            interrupt: Pause::new(),
10443        };
10444
10445        let shell = runner.state.config.shell();
10446        runner
10447            .stop_reviewing("round budget spent", &shell, &repo)
10448            .await
10449            .expect("stop_reviewing");
10450
10451        let last = runner.state.reviews.last().expect("round record");
10452        assert_eq!(
10453            last.e2e_status(),
10454            E2eStatus::ResourceBlocked,
10455            "the shared cache is still held; the attempt must read as blocked, not deferred or \
10456             failed: {last:?}"
10457        );
10458        assert_eq!(
10459            last.verified_head.as_deref(),
10460            Some(head.as_str()),
10461            "which commit this attempt targeted is known even though nothing finished checking \
10462             it"
10463        );
10464        let first_attempt_at = last
10465            .verified_at
10466            .expect("when this attempt ran is known too");
10467        assert_ne!(
10468            runner.state.status,
10469            RunStatus::Blocked,
10470            "contention is evidence about the machine, not the patch — it must not settle the \
10471             run as blocked: {:?}",
10472            runner.state.status
10473        );
10474        assert!(
10475            !runner
10476                .state
10477                .events
10478                .iter()
10479                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10480            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10481            runner.state.events
10482        );
10483
10484        // The cache is still held: a later reentry must retry the same
10485        // round's verification again — not leave it looking exactly as
10486        // untouched as the first blocked attempt, which is indistinguishable
10487        // from never having tried again at all.
10488        runner
10489            .stop_reviewing("round budget spent", &shell, &repo)
10490            .await
10491            .expect("stop_reviewing retry");
10492        assert_eq!(
10493            runner.state.reviews.len(),
10494            1,
10495            "no new round was started: {:?}",
10496            runner.state.reviews
10497        );
10498        let last = runner.state.reviews.last().expect("round record");
10499        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10500        assert!(
10501            last.verified_at.expect("still known") > first_attempt_at,
10502            "a second reentry must be a fresh attempt, not a stale copy of the first"
10503        );
10504        assert_ne!(runner.state.status, RunStatus::Blocked);
10505
10506        held.release();
10507    }
10508
10509    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
10510    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
10511    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
10512    /// own top-of-function fast path (`review_conclusion`) correctly reads
10513    /// this shape as `None` rather than guessing `Blocked`, and the loop's
10514    /// own `for` range is empty once the round budget is spent, so
10515    /// `review_loop` must retry the check itself rather than silently doing
10516    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
10517    /// above exercises directly, but through `review_loop`'s own entry point
10518    /// this time, proving the wiring between the two rather than just the
10519    /// retry logic in isolation.
10520    #[tokio::test]
10521    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10522        crate::run::pin_test_home();
10523        let home = crate::run::home();
10524
10525        let tmp = tempfile::tempdir().expect("tempdir");
10526        let repo = tmp.path().join("repo");
10527        std::fs::create_dir_all(&repo).unwrap();
10528        init_repo(&repo);
10529        let head = crate::git::rev_parse(&repo, "HEAD")
10530            .await
10531            .expect("rev-parse");
10532        let cache_dir = tmp.path().join("target");
10533
10534        let mut config = Config::default();
10535        config.verify.e2e = vec![format!(
10536            "CARGO_TARGET_DIR='{}' test -f README.md",
10537            cache_dir.display()
10538        )];
10539        config.graph.review_rounds = 1;
10540        config.graph.timeout_verify = Some(2);
10541
10542        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10543        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10544            .expect("no io error acquiring directly")
10545        {
10546            crate::cache::AcquireOutcome::Acquired(g) => g,
10547            crate::cache::AcquireOutcome::Busy(b) => {
10548                panic!("expected the direct acquire to win the lease first: {b:?}")
10549            }
10550        };
10551
10552        let mut state = RunState::new(
10553            repo.clone(),
10554            "main".to_owned(),
10555            head.clone(),
10556            "task".to_owned(),
10557            config,
10558        );
10559        state.candidates = vec![Candidate {
10560            index: 0,
10561            label: 'A',
10562            agent: "alpha".to_owned(),
10563            branch: "does-not-exist".to_owned(),
10564            worktree: repo.clone(),
10565            summary: String::new(),
10566            stat: String::new(),
10567            files: 0,
10568            commits: 0,
10569            empty: false,
10570            failed: None,
10571            verified_noop: None,
10572            duration_ms: 0,
10573            folded: false,
10574        }];
10575        state.tally = Some(Tally {
10576            first_choice: BTreeMap::from([('A', 1)]),
10577            borda: BTreeMap::new(),
10578            winner: 'A',
10579            rankings: 1,
10580            unanimous_initial: true,
10581            deliberated: false,
10582            changed_votes: 0,
10583            unanimous_final: true,
10584            tie_break: None,
10585            judges: 0,
10586            present: 0,
10587            quorum: 0,
10588            met_quorum: true,
10589            uncontested: Some("only candidate A produced a change".to_owned()),
10590        });
10591        // The exact shape a prior process's `stop_reviewing` would have left
10592        // on disk: the round budget's last round, a real attempt already
10593        // made and already resource-blocked.
10594        state.reviews = vec![ReviewRound {
10595            round: 1,
10596            head: head.clone(),
10597            verified_head: Some(head.clone()),
10598            verified_at: Some(jiff::Timestamp::now()),
10599            reviews: Vec::new(),
10600            e2e: vec![CommandOutcome {
10601                command: format!(
10602                    "CARGO_TARGET_DIR='{}' test -f README.md",
10603                    cache_dir.display()
10604                ),
10605                code: None,
10606                output_tail: "waiting for the shared build cache".to_owned(),
10607                duration_ms: 0,
10608                resource_blocked: true,
10609            }],
10610            fix: None,
10611            blocking: 1,
10612            answered: 1,
10613            expected: 1,
10614            clean: false,
10615            verify_retried: false,
10616            e2e_deferred: false,
10617            e2e_defer_reason: None,
10618            progressed: false,
10619            vote_split: false,
10620            reconsideration: Vec::new(),
10621            verdict: None,
10622        }];
10623
10624        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10625        let mut runner = Runner {
10626            state,
10627            roles: ResolvedRoles {
10628                implementers: Vec::new(),
10629                judges: Vec::new(),
10630                reviewers: Vec::new(),
10631                fixer: None,
10632                conductor: conductor(),
10633                implementer_roster: Vec::new(),
10634                judge_roster: Vec::new(),
10635                reviewer_roster: Vec::new(),
10636            },
10637            sem: Arc::new(Semaphore::new(1)),
10638            pause: Pause::new(),
10639            interrupt: Pause::new(),
10640        };
10641
10642        // The lease is still held throughout, so this reentry's own retry is
10643        // also contended — proving `review_loop` actually tried again (not
10644        // that it happened to succeed) is what the timestamp comparison
10645        // below is for.
10646        runner.review_loop().await.expect("review_loop");
10647
10648        assert_eq!(
10649            runner.state.reviews.len(),
10650            1,
10651            "no new round was started on top of the unresolved one: {:?}",
10652            runner.state.reviews
10653        );
10654        let last = &runner.state.reviews[0];
10655        assert_eq!(
10656            last.e2e_status(),
10657            E2eStatus::ResourceBlocked,
10658            "still contended: {last:?}"
10659        );
10660        assert!(
10661            last.verified_at.expect("still known") > first_attempt_at,
10662            "review_loop must have actually retried the check, not left it exactly as found"
10663        );
10664        assert_ne!(
10665            runner.state.status,
10666            RunStatus::Blocked,
10667            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10668            runner.state.status
10669        );
10670
10671        held.release();
10672    }
10673
10674    #[tokio::test]
10675    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10676        crate::run::pin_test_home();
10677        let tmp = tempfile::tempdir().expect("tempdir");
10678        let repo = tmp.path().join("repo");
10679        std::fs::create_dir_all(&repo).unwrap();
10680        init_repo(&repo);
10681
10682        let mut config = Config::default();
10683        config.merge.mode = MergeMode::Pr;
10684        config.graph.land = true;
10685        config.graph.land_approval = false;
10686
10687        let mut state = RunState::new(
10688            repo.clone(),
10689            "main".to_owned(),
10690            "deadbeef".to_owned(),
10691            "task".to_owned(),
10692            config,
10693        );
10694        state.candidates = vec![Candidate {
10695            index: 0,
10696            label: 'A',
10697            agent: "alpha".to_owned(),
10698            branch: "does-not-exist".to_owned(),
10699            worktree: repo.clone(),
10700            summary: String::new(),
10701            stat: String::new(),
10702            files: 0,
10703            commits: 0,
10704            empty: false,
10705            failed: None,
10706            verified_noop: None,
10707            duration_ms: 0,
10708            folded: false,
10709        }];
10710        state.tally = Some(Tally {
10711            first_choice: BTreeMap::from([('A', 1)]),
10712            borda: BTreeMap::new(),
10713            winner: 'A',
10714            rankings: 1,
10715            unanimous_initial: true,
10716            deliberated: false,
10717            changed_votes: 0,
10718            unanimous_final: true,
10719            tie_break: None,
10720            judges: 0,
10721            present: 0,
10722            quorum: 0,
10723            met_quorum: true,
10724            uncontested: Some("only candidate A produced a change".to_owned()),
10725        });
10726        state.reviews = vec![ReviewRound {
10727            round: 1,
10728            head: "deadbeef".to_owned(),
10729            verified_head: None,
10730            verified_at: None,
10731            reviews: Vec::new(),
10732            e2e: Vec::new(),
10733            fix: None,
10734            blocking: 0,
10735            answered: 0,
10736            expected: 0,
10737            clean: true,
10738            verify_retried: false,
10739            e2e_deferred: false,
10740            e2e_defer_reason: None,
10741            progressed: false,
10742            vote_split: false,
10743            reconsideration: Vec::new(),
10744            verdict: None,
10745        }];
10746        state.gate = vec![CommandOutcome {
10747            command: "test".to_owned(),
10748            code: Some(0),
10749            output_tail: String::new(),
10750            duration_ms: 0,
10751            resource_blocked: false,
10752        }];
10753        state.gate_ran = true;
10754        // A first pass through `merge` already pushed and opened this pull
10755        // request; `status` is `Landing` because a previous call into `land`
10756        // parked or was interrupted before it reached a terminal outcome.
10757        state.status = RunStatus::Landing;
10758        state.merge = Some(MergeOutcome {
10759            mode: MergeMode::Pr,
10760            ok: true,
10761            detail: "https://example.invalid/x/y/pull/1".to_owned(),
10762            empty: false,
10763        });
10764
10765        // The Landing-resume shortcut calls `run_land` directly rather than
10766        // through `merge`, which is exactly the call site that used to skip
10767        // `settle_questions` - see the fixture below.
10768        ask_test_home();
10769        let store = ask::Questions::open();
10770        let q = ask_open_question(&store, &state.id);
10771
10772        let mut runner = Runner {
10773            state,
10774            roles: ResolvedRoles {
10775                implementers: Vec::new(),
10776                judges: Vec::new(),
10777                reviewers: Vec::new(),
10778                fixer: None,
10779                conductor: conductor(),
10780                implementer_roster: Vec::new(),
10781                judge_roster: Vec::new(),
10782                reviewer_roster: Vec::new(),
10783            },
10784            sem: Arc::new(Semaphore::new(1)),
10785            pause: Pause::new(),
10786            interrupt: Pause::new(),
10787        };
10788
10789        // `execute`, not `merge` directly: the Landing-resume shortcut lives
10790        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
10791        // exactly because `review_loop` would otherwise clobber the marker
10792        // first.
10793        runner.execute().await.expect("execute");
10794
10795        assert_eq!(
10796            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10797            Some("https://example.invalid/x/y/pull/1"),
10798            "reentry must not push again or open a second pull request over the \
10799             one `land` is already watching"
10800        );
10801        assert_ne!(
10802            runner.state.status,
10803            RunStatus::Landing,
10804            "land could not actually reach the fake pull request, so it must \
10805             have given up rather than left the run silently parked forever"
10806        );
10807        // `land` could not reach the fake pull request, so it gave up into
10808        // `Blocked` - still resumable, so the question must not have been
10809        // swept just because this branch now also calls `settle_questions`.
10810        assert_eq!(runner.state.status, RunStatus::Blocked);
10811        assert!(
10812            store.get(&q.id).unwrap().status.open(),
10813            "Blocked is still alive; settle_questions must have been a no-op here"
10814        );
10815    }
10816
10817    fn state_with_round(round: ReviewRound) -> RunState {
10818        let mut s = RunState::new(
10819            PathBuf::from("/repo"),
10820            "main".to_owned(),
10821            "abc1234".to_owned(),
10822            "add retries".to_owned(),
10823            Config::default(),
10824        );
10825        s.reviews = vec![round];
10826        s
10827    }
10828
10829    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10830        crate::verdict::Finding {
10831            id: id.to_owned(),
10832            severity,
10833            file: None,
10834            line: None,
10835            title: title.to_owned(),
10836            detail: String::new(),
10837        }
10838    }
10839
10840    #[test]
10841    fn pr_body_names_open_findings_and_declined_ones() {
10842        let round = ReviewRound {
10843            round: 2,
10844            head: "deadbee".to_owned(),
10845            verified_head: None,
10846            verified_at: None,
10847            reviews: vec![ReviewRecord {
10848                attempts: 0,
10849                reviewer: 1,
10850                agent: "alpha".to_owned(),
10851                summary: String::new(),
10852                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10853                vote: None,
10854                failed: None,
10855                duration_ms: 0,
10856            }],
10857            e2e: vec![CommandOutcome {
10858                command: "cargo test".to_owned(),
10859                code: Some(0),
10860                output_tail: String::new(),
10861                duration_ms: 0,
10862                resource_blocked: false,
10863            }],
10864            verify_retried: false,
10865            e2e_deferred: false,
10866            e2e_defer_reason: None,
10867            fix: Some(FixRecord {
10868                agent: "alpha".to_owned(),
10869                addressed: Vec::new(),
10870                rejected: vec![crate::verdict::Rejection {
10871                    id: "R1-1-1".to_owned(),
10872                    why: "not reachable from any caller".to_owned(),
10873                }],
10874                notes: String::new(),
10875                committed: true,
10876                failed: None,
10877                duration_ms: 0,
10878                continuation: None,
10879            }),
10880            blocking: 0,
10881            answered: 1,
10882            expected: 1,
10883            clean: false,
10884            progressed: true,
10885            vote_split: false,
10886            reconsideration: Vec::new(),
10887            verdict: None,
10888        };
10889        let state = state_with_round(round);
10890        let body = pr_message(&state, 'A').body;
10891
10892        assert!(body.contains("add retries"), "the task must still be there");
10893        assert!(body.contains("R2-1-1"), "{body}");
10894        assert!(body.contains("unused import"), "{body}");
10895        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
10896        assert!(
10897            body.contains("not reachable from any caller"),
10898            "the reason it was declined: {body}"
10899        );
10900    }
10901
10902    #[test]
10903    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
10904        let round = ReviewRound {
10905            round: 1,
10906            head: "deadbee".to_owned(),
10907            verified_head: None,
10908            verified_at: None,
10909            reviews: vec![ReviewRecord {
10910                attempts: 0,
10911                reviewer: 1,
10912                agent: "alpha".to_owned(),
10913                summary: String::new(),
10914                findings: Vec::new(),
10915                vote: None,
10916                failed: None,
10917                duration_ms: 0,
10918            }],
10919            e2e: Vec::new(),
10920            verify_retried: false,
10921            e2e_deferred: false,
10922            e2e_defer_reason: None,
10923            fix: None,
10924            blocking: 0,
10925            answered: 1,
10926            expected: 1,
10927            clean: true,
10928            progressed: false,
10929            vote_split: false,
10930            reconsideration: Vec::new(),
10931            verdict: None,
10932        };
10933        let state = state_with_round(round);
10934        let body = pr_message(&state, 'A').body;
10935        assert!(!body.contains("Open review findings"), "{body}");
10936        assert!(!body.contains("Declined"), "{body}");
10937    }
10938
10939    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
10940        let mut state = RunState::new(
10941            PathBuf::from("/repo"),
10942            "main".to_owned(),
10943            "abc1234".to_owned(),
10944            instruction.to_owned(),
10945            Config::default(),
10946        );
10947        state.candidates.push(Candidate {
10948            index: 0,
10949            label: 'A',
10950            agent: "alpha".to_owned(),
10951            branch: "magi/x/A".to_owned(),
10952            worktree: PathBuf::from("/wt"),
10953            summary: summary.to_owned(),
10954            stat: String::new(),
10955            files: 1,
10956            commits: 1,
10957            empty: false,
10958            failed: None,
10959            verified_noop: None,
10960            folded: false,
10961            duration_ms: 0,
10962        });
10963        state
10964    }
10965
10966    fn review_state(subjects: &[&str]) -> RunState {
10967        let mut state = state_with_summary(
10968            "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",
10969            "",
10970        );
10971        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
10972        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
10973        state
10974    }
10975
10976    /// A branch rebased onto a main that moved past the recorded
10977    /// `base_commit` is titled from its own first commit, never main's.
10978    #[tokio::test]
10979    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
10980        ask_test_home();
10981        let tmp = tempfile::tempdir().unwrap();
10982        let repo = tmp.path().join("repo");
10983        std::fs::create_dir_all(&repo).unwrap();
10984        init_repo(&repo);
10985        let origin = tmp.path().join("origin.git");
10986        let g = |dir: &Path, args: &[&str]| {
10987            let out = std::process::Command::new("git")
10988                .args(args)
10989                .current_dir(dir)
10990                .quiet()
10991                .output()
10992                .expect("spawn git");
10993            assert!(
10994                out.status.success(),
10995                "git {args:?}: {}",
10996                String::from_utf8_lossy(&out.stderr)
10997            );
10998        };
10999        g(
11000            tmp.path(),
11001            &[
11002                "clone",
11003                "--bare",
11004                "-q",
11005                repo.to_str().unwrap(),
11006                origin.to_str().unwrap(),
11007            ],
11008        );
11009        g(
11010            &repo,
11011            &["remote", "add", "origin", origin.to_str().unwrap()],
11012        );
11013        let c1 = git::rev_parse(&repo, "main").await.unwrap();
11014
11015        // Main moves on; the branch is built on top of the new main.
11016        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11017        g(&repo, &["add", "-A"]);
11018        g(
11019            &repo,
11020            &["commit", "-q", "-m", "chore(deps): update a crate"],
11021        );
11022        g(&repo, &["push", "-q", "origin", "main"]);
11023        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11024        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11025        g(&repo, &["add", "-A"]);
11026        g(
11027            &repo,
11028            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11029        );
11030        g(&repo, &["checkout", "-q", "main"]);
11031
11032        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11033        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11034        // Without a readable tracking ref the recorded base's merge base is used.
11035        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11036        assert_eq!(fallback, c1);
11037
11038        let mut state = review_state(&[
11039            "chore(deps): update a crate",
11040            "fix(daemon): apply a chosen action",
11041        ]);
11042        state.repo = repo.clone();
11043        state.base_branch = "main".to_owned();
11044        state.base_commit = c1;
11045        refresh_reviewed_commits(&mut state, "feat/own").await;
11046        assert_eq!(
11047            state.reviewed_commits,
11048            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11049        );
11050        assert_eq!(
11051            review_title(&state).as_deref(),
11052            Some("fix(daemon): apply a chosen action")
11053        );
11054        assert_eq!(
11055            leaked_subjects(&state, "feat/own").await,
11056            Some(vec!["chore(deps): update a crate".to_owned()])
11057        );
11058        // A stale tracking ref is still used when the fetch fails, but the
11059        // leak list is withheld.
11060        state.config.merge.remote = "nowhere".to_owned();
11061        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11062    }
11063
11064    #[test]
11065    fn pr_message_review_single_commit_uses_its_subject() {
11066        let state = review_state(&["feat(nats): per-role user"]);
11067        let m = pr_message(&state, 'A');
11068        assert_eq!(m.title, "feat(nats): per-role user");
11069        assert!(!m.body.contains("Review the work already"), "{}", m.body);
11070        assert!(m.body.contains("## Commits under review"), "{}", m.body);
11071    }
11072
11073    #[test]
11074    fn pr_message_review_multi_commit_takes_the_oldest() {
11075        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11076        let m = pr_message(&state, 'A');
11077        assert_eq!(m.title, "feat: the change");
11078        for s in ["feat: the change", "fix: typo", "fix: again"] {
11079            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11080        }
11081    }
11082
11083    #[test]
11084    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11085        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11086            let mut state = review_state(&[first, "fix: later fixup"]);
11087            state.candidates[0].branch = "機能/ログイン".to_owned();
11088            let m = pr_message(&state, 'A');
11089            assert!(
11090                m.title.starts_with("chore: land candidate A of run"),
11091                "{}",
11092                m.title
11093            );
11094        }
11095    }
11096
11097    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11098        BranchFacts {
11099            commits: commits
11100                .iter()
11101                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11102                .collect(),
11103            stat: stat.to_owned(),
11104        }
11105    }
11106
11107    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11108        let mut r = review_round(true, 0, 1, 1, true, true);
11109        r.round = round;
11110        r.fix = notes.map(|n| FixRecord {
11111            agent: "fixer".to_owned(),
11112            addressed: Vec::new(),
11113            rejected: Vec::new(),
11114            notes: n.to_owned(),
11115            committed: true,
11116            failed: None,
11117            duration_ms: 0,
11118            continuation: None,
11119        });
11120        r
11121    }
11122
11123    #[test]
11124    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11125        let state = review_state(&["ignored"]);
11126        let f = facts(
11127            &[
11128                (
11129                    "fix(login): resolve PATH on macOS",
11130                    "Login shells skip rc files.",
11131                ),
11132                ("fix: address review", ""),
11133            ],
11134            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11135        );
11136        let m = pr_message_with(&state, 'A', Some(&f));
11137        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11138        assert!(
11139            m.body
11140                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
11141            "{}",
11142            m.body
11143        );
11144        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11145        assert!(m.body.contains("## Diff stat"), "{}", m.body);
11146        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11147        for banned in [
11148            "Review the work already",
11149            "no task statement",
11150            "Original task",
11151        ] {
11152            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11153        }
11154        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11155    }
11156
11157    #[test]
11158    fn pr_message_review_truncates_a_huge_first_commit_body() {
11159        let state = review_state(&["ignored"]);
11160        let f = facts(
11161            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11162            "s",
11163        );
11164        let m = pr_message_with(&state, 'A', Some(&f));
11165        assert!(m.body.len() < 30_000, "{}", m.body.len());
11166        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11167        assert!(
11168            m.body.contains("- ... 1 more commit(s)"),
11169            "{}",
11170            m.body.len()
11171        );
11172        assert!(m.body.ends_with("magi:candidate-a\n"));
11173    }
11174
11175    #[test]
11176    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11177        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11178        assert_eq!(m.title, "feat: x");
11179        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11180        assert!(!m.body.contains("Diff stat"), "{}", m.body);
11181        assert!(!m.body.contains("no task statement"), "{}", m.body);
11182    }
11183
11184    #[test]
11185    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11186        let mut state = review_state(&["日本語の件名"]);
11187        state.candidates[0].branch = "fix/macos-login-path".to_owned();
11188        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11189        state.candidates[0].branch = "機能/ログイン".to_owned();
11190        assert!(
11191            pr_message(&state, 'A')
11192                .title
11193                .starts_with("chore: land candidate A")
11194        );
11195    }
11196
11197    #[test]
11198    fn pr_message_review_fixes_survive_a_clean_final_round() {
11199        let mut state = review_state(&["feat: x"]);
11200        state.reviews = vec![
11201            round_with_notes(1, Some("handled the PATH case")),
11202            round_with_notes(2, None),
11203        ];
11204        let body = pr_message(&state, 'A').body;
11205        assert!(
11206            body.contains("## Review fixes\n\nhandled the PATH case\n"),
11207            "{body}"
11208        );
11209        assert!(!body.contains("### Round"), "{body}");
11210
11211        state.reviews = vec![
11212            round_with_notes(1, Some("first fix")),
11213            round_with_notes(2, Some("")),
11214            round_with_notes(3, Some("second fix")),
11215            round_with_notes(4, None),
11216        ];
11217        let body = pr_message(&state, 'A').body;
11218        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11219        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11220        assert!(!body.contains("### Round 2"), "{body}");
11221    }
11222
11223    #[test]
11224    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11225        let state = state_with_summary(
11226            "add retries to the client",
11227            "TITLE: feat: retries\n\nDid it.",
11228        );
11229        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11230        assert_eq!(m.title, "feat: retries");
11231        assert!(
11232            m.body.contains("<summary>Original task</summary>"),
11233            "{}",
11234            m.body
11235        );
11236        assert!(!m.body.contains("Commits under review"), "{}", m.body);
11237        assert!(
11238            m.body
11239                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11240            "{}",
11241            m.body
11242        );
11243    }
11244
11245    #[test]
11246    fn pr_message_review_bounds_a_long_english_subject() {
11247        let long = format!("feat: {}", "word ".repeat(100));
11248        let m = pr_message(&review_state(&[&long]), 'A');
11249        assert!(m.title.starts_with("feat: word"), "{}", m.title);
11250        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11251    }
11252
11253    #[test]
11254    fn pr_message_implementation_run_is_unchanged_by_review_support() {
11255        let state = state_with_summary("add retries\n\ndetails", "- did some things");
11256        let m = pr_message(&state, 'A');
11257        assert_eq!(m.title, "add retries");
11258        assert!(m.body.contains("<summary>Original task</summary>"));
11259        assert!(!m.body.contains("Commits under review"));
11260        assert_eq!(landing_subject_source(&state), state.instruction);
11261    }
11262
11263    #[test]
11264    fn review_run_squash_subject_is_the_change_not_the_prompt() {
11265        let state = review_state(&["feat: the change", "fix: typo"]);
11266        let source = landing_subject_source(&state);
11267        assert_eq!(land::merge_subject("", &source), "feat: the change");
11268        assert_eq!(
11269            land::merge_subject("magi: candidate A (uncommitted work)", &source),
11270            "feat: the change"
11271        );
11272        // An operator's rename still wins.
11273        assert_eq!(
11274            land::merge_subject("feat: renamed by hand", &source),
11275            "feat: renamed by hand"
11276        );
11277        let blank = review_state(&["日本語"]);
11278        assert!(
11279            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11280        );
11281    }
11282
11283    #[test]
11284    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11285        let state = review_state(&["feat: the change"]);
11286        let source = landing_subject_source(&state);
11287        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11288        assert_eq!(
11289            land::merge_subject(landing_title(&state, old), &source),
11290            "feat: the change"
11291        );
11292        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11293        let task = state_with_summary("add retries", "");
11294        assert_eq!(landing_title(&task, old), old);
11295    }
11296
11297    #[test]
11298    fn pr_message_describes_the_change_not_the_task() {
11299        let state = state_with_summary(
11300            "今回やってほしいこと: results projector を直す",
11301            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11302        );
11303        let m = pr_message(&state, 'A');
11304        assert_eq!(m.title, "fix(web): batch the runs list reads");
11305        assert!(
11306            m.body.starts_with("## Summary\n\n- reads run.json once"),
11307            "{}",
11308            m.body
11309        );
11310        assert!(!m.body.contains("TITLE:"), "{}", m.body);
11311        let task_at = m.body.find("今回やってほしいこと").unwrap();
11312        let details_at = m.body.find("<details>").unwrap();
11313        assert!(
11314            details_at < task_at,
11315            "the task lives inside <details>: {}",
11316            m.body
11317        );
11318        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11319        assert!(m.body.contains("magi:candidate-a"));
11320    }
11321
11322    #[test]
11323    fn pr_message_falls_back_to_the_task_without_a_title_line() {
11324        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11325        let m = pr_message(&state, 'A');
11326        assert_eq!(m.title, "add retries");
11327        assert!(
11328            m.body.contains("## Summary\n\n- did some things"),
11329            "{}",
11330            m.body
11331        );
11332
11333        let none = RunState::new(
11334            PathBuf::from("/repo"),
11335            "main".to_owned(),
11336            "abc1234".to_owned(),
11337            "add retries".to_owned(),
11338            Config::default(),
11339        );
11340        let m = pr_message(&none, 'A');
11341        assert_eq!(m.title, "add retries");
11342        assert!(!m.body.contains("## Summary"), "{}", m.body);
11343    }
11344
11345    #[test]
11346    fn pr_message_refuses_the_candidate_commit_subject() {
11347        for bad in [
11348            "TITLE: magi: candidate A (uncommitted work)",
11349            "TITLE: chore: stuff (uncommitted work)",
11350            "TITLE:   ",
11351        ] {
11352            let state = state_with_summary("add retries", bad);
11353            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11354        }
11355    }
11356
11357    #[test]
11358    fn pr_message_bounds_a_very_long_task_and_title() {
11359        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11360        let state = state_with_summary(&long, "- nothing");
11361        let m = pr_message(&state, 'A');
11362        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11363        assert!(!m.title.contains('\n'));
11364
11365        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11366        let m = pr_message(&state, 'A');
11367        assert!(m.title.starts_with("feat: "));
11368        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11369        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11370    }
11371
11372    fn long_title_of(instruction: &str) -> String {
11373        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11374    }
11375
11376    #[test]
11377    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11378        let first = "Make the landing path keep a readable title for long tasks";
11379        let line = format!(
11380            "{first}. {}",
11381            "And then keep going with more words ".repeat(20)
11382        );
11383        let t = long_title_of(&line);
11384        assert_eq!(t, first);
11385        assert!(!t.starts_with("chore: land"));
11386    }
11387
11388    #[test]
11389    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11390        let line = "word ".repeat(200);
11391        let t = long_title_of(&line);
11392        assert!(t.ends_with("word..."), "{t}");
11393        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11394    }
11395
11396    #[test]
11397    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11398        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11399            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11400        }
11401    }
11402
11403    #[test]
11404    fn pr_message_title_limit_is_exact() {
11405        let at = "a".repeat(PR_TITLE_MAX);
11406        assert_eq!(long_title_of(&at), at);
11407        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11408        assert!(over.ends_with("..."), "{over}");
11409        assert_eq!(over.chars().count(), PR_TITLE_MAX);
11410    }
11411
11412    #[test]
11413    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11414        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11415        let t = long_title_of(&line);
11416        assert!(t.ends_with("..."), "{t}");
11417        assert!(t.is_ascii(), "{t}");
11418    }
11419
11420    #[test]
11421    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11422        let line = format!(
11423            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11424            "plus more filler words ".repeat(20)
11425        );
11426        let t = long_title_of(&line);
11427        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11428    }
11429
11430    #[test]
11431    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11432        let line = format!("\n\n# {}", "title words ".repeat(40));
11433        let state = state_with_summary(&line, "- nothing");
11434        let m = pr_message(&state, 'A');
11435        assert!(m.title.starts_with("title words"), "{}", m.title);
11436        assert_eq!(
11437            land::merge_subject(&m.title, &landing_subject_source(&state)),
11438            m.title
11439        );
11440        // An operator's rename wins untouched.
11441        assert_eq!(
11442            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11443            "feat: renamed by hand"
11444        );
11445    }
11446
11447    #[test]
11448    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11449        // What magi itself writes stays English under any configured language,
11450        // so a future localisation of these headings fails here. (The agents'
11451        // own text is held to English by the prompt only; magi cannot check it.)
11452        let mut state = state_with_summary(
11453            "add retries",
11454            "TITLE: fix(web): batch reads\n- reads run.json once",
11455        );
11456        state.config.graph.language = "ja".to_owned();
11457        let m = pr_message(&state, 'A');
11458        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11459
11460        // The task is the operator's own text: it goes in untouched, and the
11461        // fallback title (no summary) may be in its language too.
11462        let task = "今回やってほしいこと: results projector を直す";
11463        let mut state = state_with_summary(task, "- no title line");
11464        state.config.graph.language = "ja".to_owned();
11465        let m = pr_message(&state, 'A');
11466        assert_eq!(
11467            m.title,
11468            format!("chore: land candidate A of run {}", state.id)
11469        );
11470        assert!(
11471            m.body.contains(&format!(
11472                "<summary>Original task</summary>\n\n{task}\n\n</details>"
11473            )),
11474            "{}",
11475            m.body
11476        );
11477    }
11478
11479    #[test]
11480    fn pr_message_scrubs_home_paths_and_addresses() {
11481        let state = state_with_summary(
11482            "fix it in /Users/someone/src/x",
11483            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11484        );
11485        let m = pr_message(&state, 'A');
11486        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11487            assert!(!m.body.contains(leak), "{}", m.body);
11488        }
11489        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11490    }
11491
11492    #[test]
11493    fn pr_message_survives_a_task_that_closes_details() {
11494        let state = state_with_summary("a </details> b", "TITLE: fix: x");
11495        let m = pr_message(&state, 'A');
11496        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11497    }
11498
11499    #[test]
11500    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11501        let cmd = manual_merge_command(
11502            MergeStyle::Squash,
11503            Path::new("/repo"),
11504            "b",
11505            "fix: \"quoted\" $(x) `y`\n\nbody",
11506        );
11507        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11508    }
11509
11510    #[test]
11511    fn manual_merge_command_matches_the_configured_style() {
11512        let repo = Path::new("/repo");
11513        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11514
11515        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11516        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11517
11518        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11519        assert_eq!(
11520            squash,
11521            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11522             \"Merge magi run 0832 (candidate A)\""
11523        );
11524
11525        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11526        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11527    }
11528
11529    #[test]
11530    fn a_nudge_gets_a_quarter_of_the_budget() {
11531        // The judge and implement budgets magi ships with.
11532        assert_eq!(retry_budget(secs(1200), true), secs(300));
11533        assert_eq!(retry_budget(secs(3600), true), secs(900));
11534    }
11535
11536    #[test]
11537    fn a_resent_prompt_keeps_the_whole_budget() {
11538        // The seat kept no context, so the retry is the original job again and
11539        // shortening it would only guarantee a second failure.
11540        assert_eq!(retry_budget(secs(1200), false), secs(1200));
11541        assert_eq!(retry_budget(secs(60), false), secs(60));
11542    }
11543
11544    #[test]
11545    fn the_floor_never_exceeds_the_original_budget() {
11546        // A short configured timeout must not be *raised* by the floor: the
11547        // operator asked for a bound, and a retry may not outlast the attempt
11548        // it is retrying.
11549        assert_eq!(retry_budget(secs(60), true), secs(60));
11550        assert_eq!(retry_budget(secs(480), true), secs(120));
11551        assert_eq!(retry_budget(secs(0), true), secs(0));
11552    }
11553
11554    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
11555        agent::CommandEvidence {
11556            id: "item1".to_owned(),
11557            description: "cargo test".to_owned(),
11558            exit_code,
11559            result_summary: String::new(),
11560            source: "codex".to_owned(),
11561        }
11562    }
11563
11564    #[test]
11565    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
11566        // No evidence is not the same fact as unconfirmed evidence: a
11567        // backend with no adapter, or a reply that ran no commands at all,
11568        // must not be misread as carrying a dangling job.
11569        assert!(!has_unconfirmed_command(&[]));
11570    }
11571
11572    #[test]
11573    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
11574        // Deliberately not a check on the exit code's *value*: a fixer
11575        // legitimately runs something that fails mid-iteration before it
11576        // succeeds, and that must never by itself reopen a valid report.
11577        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
11578        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
11579        assert!(!has_unconfirmed_command(&[
11580            evidence(Some(0)),
11581            evidence(Some(101))
11582        ]));
11583    }
11584
11585    #[test]
11586    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
11587        assert!(has_unconfirmed_command(&[
11588            evidence(Some(0)),
11589            evidence(None)
11590        ]));
11591    }
11592
11593    #[test]
11594    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
11595        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11596        assert_eq!(
11597            verified_noop_claim(true, &[], text).as_deref(),
11598            Some("already fixed by b32cfc4, on main.")
11599        );
11600    }
11601
11602    #[test]
11603    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
11604        // A timeout or a bad exit code reads as the ordinary loss it is,
11605        // whatever the reply's own prose claims.
11606        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11607        assert!(verified_noop_claim(false, &[], text).is_none());
11608    }
11609
11610    #[test]
11611    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
11612        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11613        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
11614        // A confirmed command alongside the marker is fine.
11615        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
11616    }
11617
11618    #[test]
11619    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
11620        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
11621    }
11622
11623    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
11624    /// pair, in order, labelled A, B, C, ...
11625    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
11626        runner.state.candidates = shape
11627            .iter()
11628            .enumerate()
11629            .map(|(i, &(empty, verified))| Candidate {
11630                index: i,
11631                label: (b'A' + i as u8) as char,
11632                agent: "sonnet".to_owned(),
11633                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
11634                worktree: PathBuf::from(format!("/wt/{i}")),
11635                summary: String::new(),
11636                stat: String::new(),
11637                files: 0,
11638                commits: 0,
11639                empty,
11640                failed: None,
11641                verified_noop: verified.map(str::to_owned),
11642                duration_ms: 0,
11643                folded: false,
11644            })
11645            .collect();
11646    }
11647
11648    #[test]
11649    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
11650        ask_test_home();
11651        let mut runner = runner_at(RunStatus::Implementing);
11652        set_candidates(
11653            &mut runner,
11654            &[
11655                (true, Some("already on main at b32cfc4")),
11656                (true, Some("same fix, see the existing test")),
11657            ],
11658        );
11659
11660        runner
11661            .after_implement()
11662            .expect("a verified no-op is not an error");
11663
11664        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
11665    }
11666
11667    #[test]
11668    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
11669        ask_test_home();
11670        let mut runner = runner_at(RunStatus::Implementing);
11671        // Candidate A declares a verified no-op; candidate B simply wrote
11672        // nothing and said nothing about why. One candidate's claim is not
11673        // the whole run's agreement.
11674        set_candidates(
11675            &mut runner,
11676            &[(true, Some("already on main at b32cfc4")), (true, None)],
11677        );
11678
11679        let err = runner
11680            .after_implement()
11681            .expect_err("an unverified empty candidate must still fail the run");
11682
11683        assert!(
11684            err.to_string().contains("no candidate produced a change"),
11685            "{err}"
11686        );
11687        assert_eq!(runner.state.status, RunStatus::Failed);
11688    }
11689
11690    #[test]
11691    fn after_implement_still_fails_an_ordinary_all_empty_run() {
11692        ask_test_home();
11693        let mut runner = runner_at(RunStatus::Implementing);
11694        set_candidates(&mut runner, &[(true, None), (true, None)]);
11695
11696        let err = runner
11697            .after_implement()
11698            .expect_err("no candidate declared anything; this is an ordinary failure");
11699
11700        assert!(
11701            err.to_string().contains("no candidate produced a change"),
11702            "{err}"
11703        );
11704        assert_eq!(runner.state.status, RunStatus::Failed);
11705    }
11706}