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    // No fallback to the local branch: it may be behind, and branching off an
478    // old commit is the stale-checkout bug this check exists to prevent.
479    bail!(
480        "cannot read {tracking} ({why}); refusing to branch off the local \
481         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
482         base / remote in magi.toml"
483    )
484}
485
486/// Exclusive claim on one run's `magi fix` step, released on drop — including
487/// on an early return or a panic.
488///
489/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
490/// manual `magi fix` invocations against the same run are otherwise
491/// invisible to each other and would race to remove and recreate the same
492/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
493/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
494/// which is only ever reclaimed later, out of band, by
495/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
496/// `magi fix` invocation is not necessarily running under either of those, so
497/// nothing would ever sweep a lock a killed or crashed process left behind.
498/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
499/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
500/// lease use: an unreadable or unparsable pid, or a liveness query the
501/// platform cannot answer, reads as alive and the lock is left in place.
502struct FixClaim {
503    path: PathBuf,
504}
505
506impl FixClaim {
507    fn acquire(dir: &Path) -> Result<Self> {
508        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
509        let path = dir.join("fix.lock");
510        match Self::create(&path) {
511            Ok(claim) => Ok(claim),
512            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
513                if Self::reclaim_if_dead(&path) {
514                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
515                } else {
516                    bail!(
517                        "another `magi fix` is already running for this run ({} exists)",
518                        path.display()
519                    )
520                }
521            }
522            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
523        }
524    }
525
526    fn create(path: &Path) -> std::io::Result<Self> {
527        let mut f = std::fs::OpenOptions::new()
528            .write(true)
529            .create_new(true)
530            .open(path)?;
531        use std::io::Write as _;
532        // Read back by `reclaim_if_dead` on a later, stuck invocation.
533        writeln!(f, "{}", std::process::id())?;
534        Ok(Self {
535            path: path.to_owned(),
536        })
537    }
538
539    /// True if the lock named a process confirmed dead, in which case it was
540    /// also removed. Never true on an unreadable file, an unparsable pid, or
541    /// a liveness query the platform cannot answer — see this type's own doc.
542    fn reclaim_if_dead(path: &Path) -> bool {
543        let dead = std::fs::read_to_string(path)
544            .ok()
545            .and_then(|body| body.trim().parse::<u32>().ok())
546            .is_some_and(|pid| !crate::proc::pid_alive(pid));
547        dead && std::fs::remove_file(path).is_ok()
548    }
549}
550
551impl Drop for FixClaim {
552    fn drop(&mut self) {
553        let _ = std::fs::remove_file(&self.path);
554    }
555}
556
557impl Runner {
558    /// Start a fresh run against `repo`.
559    pub async fn start(
560        repo: &Path,
561        instruction: String,
562        config: Config,
563        origin: Origin,
564    ) -> Result<Self> {
565        Self::start_naming(repo, instruction, "", config, origin).await
566    }
567
568    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
569    /// searched for branch and commit references along with the instruction,
570    /// since a task may name the work it is about only in its title.
571    pub async fn start_naming(
572        repo: &Path,
573        instruction: String,
574        also_scan: &str,
575        config: Config,
576        origin: Origin,
577    ) -> Result<Self> {
578        let repo = git::toplevel(repo).await?;
579        let missing = agent::missing_programs(&config.agents);
580        if !missing.is_empty() {
581            bail!(
582                "these agent programs are not on PATH: {}. Fix the roster in \
583                 magi.toml or install them.",
584                missing.join(", ")
585            );
586        }
587        let base_branch =
588            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
589                .await?;
590        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
591        let roles = config.resolve_roles()?;
592        let max_parallel = config.graph.max_parallel.max(1);
593        // A task that points at work already in the repository starts from
594        // it; what the repository says about each reference is recorded.
595        let seeds = refs::resolve(
596            &repo,
597            &base_commit,
598            &config.merge.remote,
599            &format!("{also_scan}\n{instruction}"),
600        )
601        .await;
602        refs::plan(&repo, &seeds).await?;
603        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
604        // Recorded before the first save, so a crash right after minting
605        // cannot leave a run with no origin. Legibility only: nothing reads it
606        // to decide anything.
607        state.origin = Some(origin);
608        for seed in &seeds {
609            state.event(
610                "seed",
611                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
612            );
613        }
614        state.seeds = seeds;
615        state.event("start", format!("run {} created", state.id));
616        state.save()?;
617        Ok(Self {
618            state,
619            roles,
620            sem: Arc::new(Semaphore::new(max_parallel)),
621            pause: Pause::new(),
622            interrupt: Pause::new(),
623        })
624    }
625
626    /// Open a review-only run against work that already exists on `branch`.
627    ///
628    /// The expensive half of the graph is the implement wave — measured at
629    /// 111 and 134 internal tool-loop turns on this repository, against a
630    /// handful for a judge or a reviewer. The cheap half is worth running on
631    /// hand-written work too, and there was no way to reach it.
632    ///
633    /// No new state and no schema change are needed: a run with **one** viable
634    /// candidate and a tally already decided degrades `execute` to exactly
635    /// review → gate → merge, because `judge` skips a single-candidate field,
636    /// `deliberate` has fewer than two first choices to reconcile, `vote`
637    /// returns early, `tally` is already present and `fold_losers` has no
638    /// losers. Resuming such a run therefore does the right thing as well.
639    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
640        Self::review_taking_over(repo, branch, config, None, origin).await
641    }
642
643    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
644    /// at the same task still has `branch` checked out, its worktree is
645    /// released first if that is safe (see [`crate::handover`]), and the
646    /// review refuses with the reason if it is not. `None` is a hand-run
647    /// review: it has no earlier attempts, so only a worktree of a dead run
648    /// magi recorded itself can be released.
649    pub async fn review_taking_over(
650        repo: &Path,
651        branch: &str,
652        config: Config,
653        takeover: Option<crate::handover::Takeover>,
654        origin: Origin,
655    ) -> Result<Self> {
656        let repo = git::toplevel(repo).await?;
657        let missing = agent::missing_programs(&config.agents);
658        if !missing.is_empty() {
659            bail!(
660                "these agent programs are not on PATH: {}. Fix the roster in \
661                 magi.toml or install them.",
662                missing.join(", ")
663            );
664        }
665        let base_branch =
666            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
667                .await?;
668        if base_branch == branch {
669            bail!("`{branch}` is the base branch; there is nothing to review against");
670        }
671        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
672
673        let roles = config.resolve_roles()?;
674        let max_parallel = config.graph.max_parallel.max(1);
675        let mut state = RunState::new(
676            repo.clone(),
677            base_branch,
678            base_commit.clone(),
679            String::new(),
680            config,
681        );
682        state.origin = Some(origin);
683
684        // Released before anything else touches the branch: a stale local
685        // branch is moved with `git branch -f`, which git refuses while an
686        // earlier attempt's worktree still has it checked out. Everything
687        // after this point that can fail puts the old run back.
688        // A hand-run review has no task, hence no earlier attempts, but a
689        // worktree of a dead run magi made may still be released.
690        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
691            earlier: Vec::new(),
692            home: crate::run::home(),
693            choice: None,
694        });
695        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
696        if let Some(released) = &released {
697            state.event(
698                "release",
699                format!(
700                    "took `{branch}` over from run {}: its worktree was released: {}",
701                    crate::run::short_of(&released.old_id),
702                    released.audit
703                ),
704            );
705        }
706        // The owner's answer to an earlier divergence question is applied
707        // here: after the release (git will not move a checked-out branch)
708        // and before the sync that would otherwise ask again.
709        if let Some(choice) = takeover.choice.as_ref()
710            && let Err(e) =
711                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
712                    .await
713        {
714            if let Some(released) = &released {
715                released.restore(&repo, branch).await;
716            }
717            return Err(e.context("applying the owner's answer about the diverged branch"));
718        }
719        let opened =
720            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
721        if opened.is_err()
722            && let Some(released) = &released
723        {
724            released.restore(&repo, branch).await;
725        }
726        opened
727    }
728
729    /// The half of [`Runner::review_taking_over`] that can fail after an
730    /// earlier attempt's worktree was released.
731    async fn open_review(
732        repo: &Path,
733        branch: &str,
734        mut state: RunState,
735        roles: ResolvedRoles,
736        max_parallel: usize,
737        base_commit: String,
738    ) -> Result<Self> {
739        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
740        // The commit subjects are the closest thing to a task statement that
741        // existing work carries, and the reviewers are told as much.
742        let start = review_base(
743            repo,
744            &state.config.merge.remote,
745            &state.base_branch,
746            &base_commit,
747            branch,
748        )
749        .await;
750        let log = git::log_oneline(repo, &start, branch)
751            .await
752            .unwrap_or_default();
753        let instruction = format!(
754            "Review the work already on branch `{branch}`. There is no task \
755             statement: what the change claims to do is whatever its commits \
756             say.\n\n{}",
757            if log.trim().is_empty() {
758                "(no commit messages)"
759            } else {
760                log.trim()
761            }
762        );
763        state.instruction = instruction;
764        state.reviewed_commits = Some(
765            git::subjects(repo, &start, branch)
766                .await
767                .unwrap_or_default(),
768        );
769
770        // An attached worktree, so the fixer's commits land on the branch under
771        // review rather than on a detached head nobody will look at again.
772        let worktree = state.worktree_root().join("under-review");
773        if let Some(parent) = worktree.parent() {
774            tokio::fs::create_dir_all(parent).await.ok();
775        }
776        let path = worktree.to_string_lossy().to_string();
777        git::git(repo, &["worktree", "add", &path, branch])
778            .await
779            .with_context(|| {
780                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
781            })?;
782
783        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
784            .await
785            .unwrap_or(0);
786        if commits == 0 {
787            git::worktree_remove(repo, &worktree).await.ok();
788            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
789        }
790        let files = git::changed_files(&worktree, &base_commit, "HEAD")
791            .await
792            .map(|f| f.len())
793            .unwrap_or(0);
794        if files == 0
795            && let (Ok(head_tree), Ok(base_tree)) = (
796                git::tree_of(&worktree, "HEAD").await,
797                git::tree_of(&worktree, &base_commit).await,
798            )
799            && head_tree == base_tree
800        {
801            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
802            git::worktree_remove(repo, &worktree).await.ok();
803            bail!(
804                "`{branch}` at {} has a tree identical to base {}; this usually means \
805                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
806                 against `{}/{branch}`) rather than an empty change",
807                short(&head),
808                short(&base_commit),
809                state.config.merge.remote
810            );
811        }
812        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
813            .await
814            .unwrap_or_default();
815
816        state.candidates.push(Candidate {
817            index: 0,
818            label: 'A',
819            // Not an agent id on purpose: nothing in the roster wrote this, and
820            // the stats tables must not credit anyone with a win for it.
821            agent: EXISTING_BRANCH.to_owned(),
822            branch: branch.to_owned(),
823            worktree,
824            summary: String::new(),
825            stat,
826            files,
827            commits,
828            empty: false,
829            failed: None,
830            verified_noop: None,
831            duration_ms: 0,
832            folded: false,
833        });
834        state.tally = Some(Tally {
835            first_choice: BTreeMap::from([('A', 0)]),
836            borda: BTreeMap::new(),
837            winner: 'A',
838            rankings: 0,
839            unanimous_initial: false,
840            deliberated: false,
841            changed_votes: 0,
842            unanimous_final: false,
843            tie_break: None,
844            // No panel sat, so no quorum applies. Zero judges is the correct
845            // number for work that never competed, and must not be reported as
846            // a collapsed panel.
847            judges: 0,
848            present: 0,
849            quorum: 0,
850            met_quorum: true,
851            uncontested: Some("review-only run: nothing competed".to_owned()),
852        });
853        state.status = RunStatus::Reviewing;
854        state.event(
855            "start",
856            format!(
857                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
858                state.id
859            ),
860        );
861        state.save()?;
862        Ok(Self {
863            state,
864            roles,
865            sem: Arc::new(Semaphore::new(max_parallel)),
866            pause: Pause::new(),
867            interrupt: Pause::new(),
868        })
869    }
870
871    /// Reopen an existing run.
872    pub fn resume(id: &str) -> Result<Self> {
873        let state = RunState::load(id)?;
874        if let Some(to) = &state.released_to {
875            bail!(
876                "run {} cannot be resumed: its worktree was released to run {}",
877                state.short(),
878                crate::run::short_of(to)
879            );
880        }
881        let roles = state.config.resolve_roles()?;
882        let max_parallel = state.config.graph.max_parallel.max(1);
883        Ok(Self {
884            state,
885            roles,
886            sem: Arc::new(Semaphore::new(max_parallel)),
887            pause: Pause::new(),
888            interrupt: Pause::new(),
889        })
890    }
891
892    /// Walk the graph to a terminal state, skipping nodes already recorded.
893    ///
894    /// Every way a run is driven - the queue loop, `magi run`, a resume from
895    /// the phone - ends here, so this is the one place a run that ended
896    /// Blocked / Stalled / Failed, or died with an error, is announced to the
897    /// notification centre. Best-effort: see [`crate::notices::raise`].
898    pub async fn execute(&mut self) -> Result<()> {
899        let result = self.execute_graph().await;
900        self.mark_driver_exited();
901        let ended = if result.is_err() {
902            Some(crate::notices::run_stopped(&self.state.id, &self.state))
903        } else {
904            crate::notices::run_ended(&self.state)
905        };
906        if let Some(notice) = ended {
907            crate::notices::raise(notice);
908        }
909        result
910    }
911
912    /// Record that this process no longer drives the run, so its pid (a
913    /// daemon's outlives the run) is not read as a live driver.
914    ///
915    /// Written onto the record as it is on disk, never this copy: another
916    /// process may have resumed the run (recording its own pid and clearing
917    /// the flag) or released its worktree since this copy was read, and
918    /// saving over that would mark a running driver dead. Only a record still
919    /// naming this process as the driver is touched.
920    fn mark_driver_exited(&mut self) {
921        self.state.driver_exited = true;
922        let pid = std::process::id();
923        let Ok(mut disk) = RunState::load(&self.state.id) else {
924            return;
925        };
926        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
927            return;
928        }
929        disk.driver_exited = true;
930        if let Err(e) = disk.save() {
931            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
932        }
933    }
934
935    async fn execute_graph(&mut self) -> Result<()> {
936        // Moving again, so it is no longer parked. Set before the walk rather
937        // than in `resume`, so every way of re-entering the graph clears it
938        // and a card cannot claim a run is waiting to be resumed while the
939        // agents are already working.
940        self.state.parked = false;
941        // Any seat this state still lists as answering belongs to whatever
942        // process last drove this run — this one included, if it crashed
943        // mid-wave. Cleared and flushed immediately, before anything else
944        // runs, so a resume can never show a seat as live when nothing is
945        // asking it anything yet; the node that actually dispatches the next
946        // wave repopulates it.
947        self.state.clear_active();
948        // Recorded in the same spot, and flushed together with the clear
949        // above: this is the pid a reader checks (`RunState::liveness`) when
950        // no daemon claim exists to answer "is a process still driving this
951        // run" — a plain `magi run` / `magi review` typed into a terminal
952        // claims nothing there. Always overwritten, never only-if-absent, so
953        // a resumed run's stale pid from a previous, possibly-dead process
954        // can never survive into this one's own report. Unlike
955        // `clear_active`, this changes on every single `execute()` call, so
956        // the save below is now unconditional rather than only-if-cleared.
957        //
958        // `driver_started_at` is recorded in the same breath, from this same
959        // pid, so `liveness` can tell a live pid that is genuinely still us
960        // apart from one the OS has since handed to an unrelated process —
961        // see that field's own doc for why the pid alone is not enough.
962        // A resume that raced a takeover: the record on disk says the worktree
963        // was handed to a later run after this copy was read. Saving over it
964        // would erase that and drive a run with nothing to run in.
965        if let Ok(disk) = RunState::load(&self.state.id)
966            && let Some(to) = &disk.released_to
967        {
968            bail!(
969                "run {} cannot continue: its worktree was released to run {}",
970                self.state.short(),
971                crate::run::short_of(to)
972            );
973        }
974        let pid = std::process::id();
975        self.state.driver_pid = Some(pid);
976        self.state.driver_started_at = crate::proc::process_started_at(pid);
977        self.state.driver_exited = false;
978        self.state.save()?;
979        // A run that already lost its quorum never resumes into the verdict
980        // machinery: `deliberate` and `vote` would otherwise clobber the
981        // stalled marker back to Voting and the run would keep going past a
982        // verdict that is no longer trustworthy. Everything already recorded is
983        // kept, so the run stays resumable (or foldable) for a human to pick up.
984        //
985        // On --resume the run gets one chance to repair itself: the seats a
986        // rate limit took out are re-asked. If their quota has since reset and
987        // the quorum is restored, the run picks up and finishes; otherwise it
988        // stays stale and still-resumable for a later retry. If it does not
989        // recover, the returned status stays `Stalled` and nothing was
990        // clobbered (the recovery only mutates entries for the lost seats).
991        if self.state.status == RunStatus::Stalled {
992            if self.recover_stall().await? {
993                self.finish_after_tally().await?;
994            } else {
995                // Still below quorum: persist the marker and stay resumable.
996                self.state.save()?;
997            }
998            return Ok(());
999        }
1000        // A run parked inside `land` - watching CI, mid fix-round, or
1001        // waiting on the owner's merge approval - resumes directly into it,
1002        // never back through `prep`. Everything before `merge` already
1003        // concluded; that is the only way `status` reaches `Landing` in the
1004        // first place. Re-walking `review_loop` first would also be actively
1005        // wrong: its own status recomputation (see its doc) treats any
1006        // clean round as reason to set `status` to `Gating`, which would
1007        // clobber this marker before `merge` ever ran, and this run would
1008        // never find its way back into `land` at all.
1009        if self.state.status == RunStatus::Landing {
1010            self.run_land().await?;
1011            // `run_land` may have settled the run right here - CI came back
1012            // green and the PR merged, say - without ever passing back
1013            // through `merge`'s own trailing call. Whatever it left `status`
1014            // as is what this has to read.
1015            self.settle_questions();
1016            return Ok(());
1017        }
1018        self.prep().await?;
1019        if self.park_here()? {
1020            return Ok(());
1021        }
1022        self.advise().await?;
1023        if self.park_here()? {
1024            return Ok(());
1025        }
1026        self.implement().await?;
1027        if self.park_here()? {
1028            return Ok(());
1029        }
1030        // `after_implement` already saved the state and settled any open
1031        // questions when it set this; nothing later in the graph has
1032        // anything to judge.
1033        if self.state.status == RunStatus::VerifiedNoop {
1034            return Ok(());
1035        }
1036        self.judge().await?;
1037        if self.park_here()? {
1038            return Ok(());
1039        }
1040        self.deliberate().await?;
1041        if self.park_here()? {
1042            return Ok(());
1043        }
1044        self.vote().await?;
1045        if self.park_here()? {
1046            return Ok(());
1047        }
1048        self.tally()?;
1049        // A verdict that lost its quorum is not trustworthy: do not review,
1050        // gate, or merge on it. Everything already done is kept, so the run
1051        // stays resumable (or foldable); the human can replace the agent that
1052        // ran out of quota and pick it up.
1053        if self.state.status == RunStatus::Stalled {
1054            // Persist the stalled marker now — the normal end-of-execute save
1055            // below is below this early return, and without it a resumed run
1056            // would reload a pre-tally status and keep going.
1057            self.state.save()?;
1058            return Ok(());
1059        }
1060        self.finish_after_tally().await?;
1061        Ok(())
1062    }
1063
1064    /// Park here if asked to, recording it in the run's own timeline.
1065    ///
1066    /// Returns whether the caller should stop walking the graph. The state is
1067    /// saved either way by the node that just finished; this adds the event so
1068    /// the operator's card says why a run that is neither finished nor moving
1069    /// is sitting where it is.
1070    fn park_here(&mut self) -> Result<bool> {
1071        // Either handle asking is enough - see `Pause`'s own doc for why
1072        // they are never the same one. `interrupt` is checked second so a
1073        // reason it carries is preferred in the message below over a plain
1074        // shutdown park racing it at the same boundary.
1075        if !self.pause.parked() && !self.interrupt.parked() {
1076            return Ok(false);
1077        }
1078        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1079            Some(reason) => format!(
1080                "parked after `{}` ({reason}) — resume to carry on from here",
1081                self.state.status.as_str()
1082            ),
1083            None => format!(
1084                "parked after `{}` — resume to carry on from here",
1085                self.state.status.as_str()
1086            ),
1087        };
1088        self.state.event("park", why);
1089        self.state.parked = true;
1090        self.state.save()?;
1091        Ok(true)
1092    }
1093
1094    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1095    pub fn on_pause(&mut self, pause: Pause) {
1096        self.pause = pause;
1097    }
1098
1099    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1100    /// scheduler asking this one run - and no other - to park so a task
1101    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1102    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1103    /// handle.
1104    pub fn watch_interrupt(&mut self, pause: Pause) {
1105        self.interrupt = pause;
1106    }
1107
1108    /// Abandon this run's own open questions, once `status` has actually
1109    /// settled rather than merely paused.
1110    ///
1111    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1112    /// either back up with the candidates, the review round and the seat
1113    /// sessions already on disk, so a question an implementer asked mid-round
1114    /// may still get a real answer read by a real resume. Only the statuses
1115    /// `resumable` excludes are actually final: the run merged, it reached
1116    /// `Ready` with nothing left to do, it failed outright with no
1117    /// established point to continue from, or every candidate agreed, with
1118    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1119    /// every one of those the seat that asked is gone for good, exactly like
1120    /// the run being deleted under `magi run rm` - so the same cleanup
1121    /// applies, worded for what actually happened instead of "the run was
1122    /// deleted".
1123    ///
1124    /// Best-effort and silent on success: called from every place `status`
1125    /// can land on one of those three, including ones a resumed run revisits,
1126    /// so it must cost nothing when there was nothing open to begin with.
1127    fn settle_questions(&mut self) {
1128        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1129            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1130        }
1131    }
1132
1133    /// The tail of the graph after a trustworthy tally: fold losers, review,
1134    /// gate, merge, and persist.
1135    async fn finish_after_tally(&mut self) -> Result<()> {
1136        self.fold_losers().await?;
1137        // Before review starts, and again right before the gate: a run's
1138        // review rounds can themselves take long enough for the base to move
1139        // a second time, and the gate is the one node whose "green" gets
1140        // acted on.
1141        self.sync_to_base().await?;
1142        if self.state.status == RunStatus::AlreadyInBase {
1143            return Ok(());
1144        }
1145        self.review_loop().await?;
1146        self.sync_to_base().await?;
1147        if self.state.status == RunStatus::AlreadyInBase {
1148            return Ok(());
1149        }
1150        self.gate().await?;
1151        self.merge().await?;
1152        self.state.save()?;
1153        Ok(())
1154    }
1155
1156    // ---------------------------------------------------------------- prep
1157
1158    async fn prep(&mut self) -> Result<()> {
1159        if !self.state.candidates.is_empty() {
1160            return Ok(());
1161        }
1162        self.state.status = RunStatus::Prep;
1163        let repo = self.state.repo.clone();
1164        let base = self.state.base_commit.clone();
1165        let plan = refs::plan(&repo, &self.state.seeds).await?;
1166        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1167        let root = self.state.worktree_root();
1168        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1169
1170        // The hook is the write-time half of the blindness contract; the
1171        // presentation filter in `blind` is the half that cannot be bypassed.
1172        let hooks_dir = self.state.dir().join("hooks");
1173        if self.state.config.blind.commit_msg_hook {
1174            std::fs::create_dir_all(&hooks_dir)
1175                .with_context(|| format!("create {}", hooks_dir.display()))?;
1176            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1177            let path = hooks_dir.join("commit-msg");
1178            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1179            make_executable(&path)?;
1180            // Ref-counted rather than a plain idempotent set: with more than
1181            // one run able to be in flight in the same repository at once
1182            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1183            // true?" check cannot tell "another run of mine still needs
1184            // this" from "nobody does", and the run that happens to finish
1185            // first would disable the hook out from under a sibling still
1186            // relying on it.
1187            git::acquire_worktree_config(&repo).await?;
1188            self.state.enabled_worktree_config = true;
1189        }
1190
1191        for (index, (spec, label)) in self
1192            .roles
1193            .implementers
1194            .clone()
1195            .into_iter()
1196            .zip(labels)
1197            .enumerate()
1198        {
1199            let branch = self.state.branch_for(label);
1200            let worktree = root.join(format!("cand-{label}"));
1201            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1202            if self.state.config.blind.commit_msg_hook {
1203                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1204            }
1205            git::local_exclude(&worktree, "/.magi/").await?;
1206            for pick in &plan.picks {
1207                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1208                    self.state.status = RunStatus::Blocked;
1209                    self.state
1210                        .event("prep", format!("cannot apply referenced commit: {e}"));
1211                    self.state.save()?;
1212                    return Err(e);
1213                }
1214            }
1215            self.state.candidates.push(Candidate {
1216                index,
1217                label,
1218                agent: spec.id.clone(),
1219                branch,
1220                worktree,
1221                summary: String::new(),
1222                stat: String::new(),
1223                files: 0,
1224                commits: 0,
1225                empty: false,
1226                failed: None,
1227                verified_noop: None,
1228                duration_ms: 0,
1229                folded: false,
1230            });
1231        }
1232
1233        for j in 1..=self.roles.judges.len() {
1234            let wt = root.join(format!("judge-{j}"));
1235            if !wt.exists() {
1236                git::worktree_add_detached(&repo, &wt, &base).await?;
1237            }
1238        }
1239
1240        // Disposable, detached checkouts for the design-deliberation stage's
1241        // advisor seats — the same shape as the judges' above, at the same
1242        // base commit, since advisors also only ever read. Sized off the
1243        // configured count directly rather than a resolved roster: unlike
1244        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1245        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1246        // `prep` has no `ResolvedRoles` field to read a count from here.
1247        if self.state.config.graph.advise {
1248            for k in 1..=self.state.config.graph.advisors {
1249                let wt = root.join(format!("advisor-{k}"));
1250                if !wt.exists() {
1251                    git::worktree_add_detached(&repo, &wt, &base).await?;
1252                }
1253            }
1254        }
1255
1256        // A judge cannot tell it is looking at its own patch — the seats keep
1257        // separate conversations — but a panel that shares agents with the
1258        // field is less independent than it looks, and that is worth saying out
1259        // loud once per run rather than leaving it in the config.
1260        let authors: Vec<&str> = self
1261            .roles
1262            .implementers
1263            .iter()
1264            .map(|a| a.id.as_str())
1265            .collect();
1266        let overlap: Vec<String> = self
1267            .roles
1268            .judges
1269            .iter()
1270            .enumerate()
1271            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1272            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1273            .collect();
1274        if !overlap.is_empty() {
1275            let note = format!(
1276                "{} also authored a candidate; blind, but the panel is less \
1277                 independent than {} distinct agents would be",
1278                overlap.join(", "),
1279                self.roles.judges.len()
1280            );
1281            self.state.event("prep", note);
1282        }
1283
1284        self.state.event(
1285            "prep",
1286            format!(
1287                "{} candidates, {} judges, base {} ({})",
1288                self.state.candidates.len(),
1289                self.roles.judges.len(),
1290                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1291                self.state.base_branch
1292            ),
1293        );
1294        self.state.status = RunStatus::Implementing;
1295        self.state.save()?;
1296        Ok(())
1297    }
1298
1299    // -------------------------------------------------------------- advise
1300
1301    /// The design-deliberation stage: independent, read-only advisor seats
1302    /// each sketch a design before any implementer touches the repository,
1303    /// and (when at least one produced a usable proposal) a synthesis seat
1304    /// blends them into a brief `implement` carries in every candidate's
1305    /// prompt.
1306    ///
1307    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1308    /// advisors` is the proposal count. Everything here is best-effort and
1309    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1310    /// that cannot reach quota, or a synthesis seat that produced nothing
1311    /// usable all leave `implement` exactly as it was before this stage
1312    /// existed — the task instruction alone — rather than failing the whole
1313    /// competition over an enrichment stage. Every outcome is still recorded
1314    /// as an event, so a run that got nothing from this stage says why.
1315    ///
1316    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1317    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1318    /// resumed run whose stage failed would re-run it, and re-spend the
1319    /// agent calls, on every reentry before `implement`.
1320    ///
1321    /// Also skipped once any candidate shows implementation progress — the
1322    /// exact predicate `implement` itself uses to decide a candidate is no
1323    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1324    /// is not enough: a run created by an older binary that predates this
1325    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1326    /// an already-`Implementing`-or-later run under this build would
1327    /// otherwise walk straight back through `prep` (a no-op once candidates
1328    /// exist) into this node and spawn every advisor seat against worktrees
1329    /// `prep` never recreated — after implementation has already started,
1330    /// which is exactly the invariant this stage exists to guarantee.
1331    async fn advise(&mut self) -> Result<()> {
1332        let implement_untouched = self
1333            .state
1334            .candidates
1335            .iter()
1336            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1337        if !self.state.config.graph.advise || self.state.advise_attempted {
1338            return Ok(());
1339        }
1340        if !implement_untouched {
1341            self.state.event(
1342                "advise",
1343                "skipping the design-deliberation stage: at least one \
1344                 candidate already shows implementation progress, so this \
1345                 run is past the point the stage exists to run before"
1346                    .to_owned(),
1347            );
1348            self.state.advise_attempted = true;
1349            self.state.save()?;
1350            return Ok(());
1351        }
1352        let run_id = self.state.id.clone();
1353        let prompts = self.state.config.prompts.clone();
1354        let instruction = self.state.instruction.clone();
1355        let language = self.state.config.graph.language.clone();
1356        let root = self.state.worktree_root();
1357        let n = self.state.config.graph.advisors;
1358        let where_recorded = self.state.dir().join("run.json");
1359
1360        let seats = match self.state.config.advisors() {
1361            Ok(seats) if !seats.is_empty() => seats,
1362            Ok(_) => {
1363                self.state.event(
1364                    "advise",
1365                    format!(
1366                        "[graph] advisors is 0; skipping the design-deliberation \
1367                         stage and continuing without a synthesis brief (see {})",
1368                        where_recorded.display()
1369                    ),
1370                );
1371                self.state.advise_attempted = true;
1372                self.state.save()?;
1373                return Ok(());
1374            }
1375            Err(e) => {
1376                self.state.event(
1377                    "advise",
1378                    format!(
1379                        "could not resolve advisor seats ({e:#}); continuing \
1380                         without a design-deliberation brief (see {})",
1381                        where_recorded.display()
1382                    ),
1383                );
1384                self.state.advise_attempted = true;
1385                self.state.save()?;
1386                return Ok(());
1387            }
1388        };
1389
1390        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1391        let artifacts = agent::artifacts_dir(&self.state.dir());
1392        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1393
1394        let mut jobs = Vec::new();
1395        for (i, spec) in seats.iter().cloned().enumerate() {
1396            let seat_key = format!("advisor-{}", i + 1);
1397            let seat = self.seat(&seat_key, &spec.id);
1398            jobs.push(SeatJob {
1399                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1400                spec,
1401                seat,
1402                cwd: worktrees[i % worktrees.len()].clone(),
1403                timeout,
1404                allow_write: false,
1405                sessions: false,
1406                artifacts: artifacts.clone(),
1407                stem: seat_key,
1408                handover: None,
1409            });
1410        }
1411
1412        self.state.event(
1413            "advise",
1414            format!(
1415                "{} advisor seat(s) sketching a design in parallel",
1416                jobs.len()
1417            ),
1418        );
1419        let mut quota_losses = Vec::new();
1420        let cache = self.state.config.cache_dir();
1421        let ctx = WaveCtx {
1422            carry_seats: false,
1423            run: &run_id,
1424            node: "advise",
1425            prompts: &prompts,
1426            cache: cache.as_deref(),
1427            round: None,
1428        };
1429        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1430        let results = ask_json_wave::<Proposal>(
1431            jobs,
1432            Arc::clone(&self.sem),
1433            self.state.config.graph.retries,
1434            &advisor_roster,
1435            &ctx,
1436            &mut quota_losses,
1437            &mut self.state,
1438            &|p: &Proposal| p.validate(),
1439        )
1440        .await;
1441        self.state.quota.extend(quota_losses);
1442
1443        let mut records = Vec::with_capacity(results.len());
1444        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1445            let agent_id = seat.agent.clone();
1446            self.state.seats.insert(seat.key.clone(), seat);
1447            match res {
1448                Ok((proposal, out)) => {
1449                    self.state
1450                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1451                    records.push(advise::AdvisorRecord::proposed(
1452                        i + 1,
1453                        agent_id,
1454                        proposal,
1455                        out.duration_ms,
1456                    ));
1457                }
1458                Err(e) => {
1459                    self.state.event(
1460                        "advise",
1461                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1462                    );
1463                    records.push(advise::AdvisorRecord::failed(
1464                        i + 1,
1465                        agent_id,
1466                        e.to_string(),
1467                    ));
1468                }
1469            }
1470        }
1471
1472        let mut advice = advise::Advice {
1473            records,
1474            synthesis: None,
1475        };
1476        if advice.proposals().is_empty() {
1477            self.state.event(
1478                "advise",
1479                "no advisor produced a usable proposal; continuing without a \
1480                 synthesis brief"
1481                    .to_owned(),
1482            );
1483        } else {
1484            match self
1485                .synthesize_brief(
1486                    &advice,
1487                    &instruction,
1488                    &language,
1489                    &worktrees[0],
1490                    &artifacts,
1491                    &run_id,
1492                    &prompts,
1493                    cache.as_deref(),
1494                )
1495                .await
1496            {
1497                Ok(Some(text)) => {
1498                    self.state.event(
1499                        "advise",
1500                        "synthesized a design brief for the implementer".to_owned(),
1501                    );
1502                    advice.synthesis = Some(text);
1503                }
1504                Ok(None) => {
1505                    self.state.event(
1506                        "advise",
1507                        "the synthesis seat produced nothing usable; continuing \
1508                         without a design brief"
1509                            .to_owned(),
1510                    );
1511                }
1512                Err(e) => {
1513                    self.state.event(
1514                        "advise",
1515                        format!("could not synthesize a design brief: {e:#}"),
1516                    );
1517                }
1518            }
1519        }
1520        advise::apply_reflection(&mut advice);
1521
1522        self.state.advice = Some(advice);
1523        self.state.advise_attempted = true;
1524        self.state.save()?;
1525        Ok(())
1526    }
1527
1528    /// The synthesis seat: reads every advisor's proposal and blends them
1529    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1530    /// out of [`Runner::advise`] only for readability — it is not called
1531    /// anywhere else.
1532    ///
1533    /// Picked the same way [`crate::talk`]'s standing conversation and
1534    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1535    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1536    /// order (a claude seat, else the first runnable agent in roster order)
1537    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1538    /// in [`crate::config`] for why a dedicated field exists here at all.
1539    #[allow(clippy::too_many_arguments)]
1540    async fn synthesize_brief(
1541        &mut self,
1542        advice: &advise::Advice,
1543        instruction: &str,
1544        language: &str,
1545        cwd: &Path,
1546        artifacts: &Path,
1547        run_id: &str,
1548        prompts: &Prompts,
1549        cache: Option<&Path>,
1550    ) -> Result<Option<String>> {
1551        let chain = agent::pick_chain(
1552            &self.state.config.agents,
1553            self.state.config.roles.synthesizer.as_ref(),
1554            &agent::installed,
1555            "synthesizer",
1556        )?;
1557        let proposals = advice.proposals();
1558        let mut prompt = prompt::with_overlay(
1559            prompt::synthesize_brief(instruction, &proposals, language),
1560            prompts.overlay("advise"),
1561        );
1562        if cache.is_some() {
1563            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1564            // below — see `prompt::build_cache_note`'s doc for why telling a
1565            // read-only seat to build through the shared cache is exactly how
1566            // a sandbox's write refusal gets misread as a defect.
1567            prompt.push('\n');
1568            prompt.push_str(&prompt::build_cache_note("advise", false));
1569        }
1570        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1571        // Each id is tried once, in order; a quota hit, error or unusable
1572        // answer moves to the next. The seat is single-turn (`sessions:
1573        // false`) and the prompt is the whole context, so a fallback agent
1574        // needs nothing carried over.
1575        let mut last = None;
1576        for (n, spec) in chain.iter().enumerate() {
1577            if n > 0 {
1578                self.state
1579                    .event("advise", format!("synthesis falling back to {}", spec.id));
1580            }
1581            let mut seat = self.seat("advise-synthesis", &spec.id);
1582            let outcome = agent::invoke(
1583                spec,
1584                &mut seat,
1585                &Invocation {
1586                    cwd,
1587                    prompt: &prompt,
1588                    timeout,
1589                    allow_write: false,
1590                    sessions: false,
1591                    artifacts,
1592                    stem: &if n == 0 {
1593                        "advise-synthesis".to_owned()
1594                    } else {
1595                        format!("advise-synthesis-{}", spec.id)
1596                    },
1597                    run: run_id,
1598                    node: "advise",
1599                    cache_dir: None,
1600                    attachments: &[],
1601                    writable: &[],
1602                },
1603            )
1604            .await;
1605            if outcome.is_ok() {
1606                self.state.seats.insert(seat.key.clone(), seat);
1607            }
1608            let advance = agent::chain_advances(&outcome);
1609            last = Some(outcome);
1610            if !advance {
1611                break;
1612            }
1613        }
1614        // Exhausted: the last attempt's result is what a single failed seat
1615        // would have produced.
1616        let out = last.expect("a chain holds at least one agent")?;
1617        if !out.usable() {
1618            return Ok(None);
1619        }
1620        let text =
1621            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1622        Ok((!text.trim().is_empty()).then_some(text))
1623    }
1624
1625    // ----------------------------------------------------------- implement
1626
1627    async fn implement(&mut self) -> Result<()> {
1628        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1629        // agent files with `magi task add` name the run that paid for it. The
1630        // prompt overlay is cloned alongside it because the waves borrow it
1631        // while `self` is mutably borrowed by the node's own bookkeeping.
1632        let run_id = self.state.id.clone();
1633        let prompts = self.state.config.prompts.clone();
1634        let todo: Vec<usize> = self
1635            .state
1636            .candidates
1637            .iter()
1638            .enumerate()
1639            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1640            .map(|(i, _)| i)
1641            .collect();
1642        if todo.is_empty() {
1643            return self.after_implement();
1644        }
1645        self.state.status = RunStatus::Implementing;
1646
1647        let language = self.state.config.graph.language.clone();
1648        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1649        let sessions = self.state.config.graph.sessions;
1650        let artifacts = agent::artifacts_dir(&self.state.dir());
1651        // The design-deliberation stage's blended brief, when `advise` found
1652        // one — carried into every implementer's prompt the same way
1653        // regardless of which candidate it is.
1654        let brief = self
1655            .state
1656            .advice
1657            .as_ref()
1658            .and_then(|a| a.synthesis.as_deref())
1659            .map(str::to_owned);
1660        let attachments = self.state.attachments.clone();
1661
1662        let mut jobs = Vec::new();
1663        for &i in &todo {
1664            let (index, label, worktree) = {
1665                let c = &self.state.candidates[i];
1666                (c.index, c.label, c.worktree.clone())
1667            };
1668            let spec = self.roles.implementers[index].clone();
1669            let seat_key = format!("impl-{label}");
1670            let seat = self.seat(&seat_key, &spec.id);
1671            let instruction = seeded_instruction(&self.state);
1672            jobs.push(SeatJob {
1673                spec,
1674                seat,
1675                prompt: prompt::implement(
1676                    &instruction,
1677                    &worktree.to_string_lossy(),
1678                    &language,
1679                    brief.as_deref(),
1680                    &attachments,
1681                ),
1682                cwd: worktree,
1683                timeout,
1684                allow_write: true,
1685                sessions,
1686                artifacts: artifacts.clone(),
1687                stem: format!("impl-{label}"),
1688                handover: None,
1689            });
1690        }
1691
1692        self.state.event(
1693            "implement",
1694            format!("{} candidates in parallel", jobs.len()),
1695        );
1696        // Kept so a seat whose CLI hung up can be asked again from the same
1697        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1698        // can update a seat's own entry once a fallback agent takes it over —
1699        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1700        // whichever agent actually answered, not the one that quota'd out.
1701        let mut sent = jobs.clone();
1702        let cache = self.state.config.cache_dir();
1703        let ctx = WaveCtx {
1704            carry_seats: false,
1705            run: &run_id,
1706            node: "implement",
1707            prompts: &prompts,
1708            cache: cache.as_deref(),
1709            round: None,
1710        };
1711        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1712        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1713            .await;
1714        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1715            .await;
1716        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1717            .await;
1718
1719        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1720            let seat_key = seat.key.clone();
1721            // A quota fallback (`resume_seat_handovers`) may have handed this
1722            // seat to a different agent than the one `prep` recorded on the
1723            // candidate; the stats tables and any later fixer-defaults-to-
1724            // winner's-author lookup must credit whoever actually answered —
1725            // unless every fallback also quota'd out, in which case nobody
1726            // actually answered and crediting the last agent tried would
1727            // erase every earlier agent's own quota loss from the stats
1728            // tables instead of just this one seat's.
1729            let agent = seat.agent.clone();
1730            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1731            self.state.seats.insert(seat.key.clone(), seat);
1732            let label = self.state.candidates[i].label;
1733            let worktree = self.state.candidates[i].worktree.clone();
1734            let base = self.state.base_commit.clone();
1735
1736            let (summary, duration, failed, verified_claim) = match out {
1737                AgentOutcome::Ok(o) => {
1738                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1739                    let failed = (!o.usable()).then(|| {
1740                        if o.timed_out {
1741                            "agent timed out".to_owned()
1742                        } else {
1743                            format!("agent exited with {:?}", o.exit_code)
1744                        }
1745                    });
1746                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1747                    (text, o.duration_ms, failed, verified_claim)
1748                }
1749                // Left un-resumed by `resume_undelivered` (a dirty tree
1750                // already rescues the work, or there was no session left to
1751                // resume into) — reported like the ordinary failure it is,
1752                // never as if `o.text` (the CLI's raw error JSON) were an
1753                // answer.
1754                AgentOutcome::Dropped(o) => {
1755                    let why = o
1756                        .dropped
1757                        .as_ref()
1758                        .map(|d| d.why.as_str())
1759                        .unwrap_or("the CLI ended the stream without delivering its answer");
1760                    (
1761                        String::new(),
1762                        o.duration_ms,
1763                        Some(format!("the CLI dropped the stream ({why})")),
1764                        None,
1765                    )
1766                }
1767                AgentOutcome::Quota(o) => {
1768                    self.state.quota.push(QuotaLoss {
1769                        seat: seat_key,
1770                        node: "implement".to_owned(),
1771                        at: Timestamp::now(),
1772                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1773                    });
1774                    (
1775                        String::new(),
1776                        o.duration_ms,
1777                        Some("rate limited (quota); produced no change".to_owned()),
1778                        None,
1779                    )
1780                }
1781                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1782            };
1783
1784            // Rescue anything the agent edited but never committed: an
1785            // uncommitted candidate would silently be an empty one.
1786            let rescued = match git::rescue_commit(
1787                &worktree,
1788                &format!("magi: candidate {label} (uncommitted work)"),
1789            )
1790            .await
1791            {
1792                Ok(r) => {
1793                    self.state.note_withheld("implement", &r.withheld);
1794                    r.committed
1795                }
1796                Err(_) => false,
1797            };
1798            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1799                .await
1800                .unwrap_or(0);
1801            let patch = git::diff(&worktree, &base, "HEAD")
1802                .await
1803                .unwrap_or_default();
1804            let stat = git::diff_stat(&worktree, &base, "HEAD")
1805                .await
1806                .unwrap_or_default();
1807            let files = git::changed_files(&worktree, &base, "HEAD")
1808                .await
1809                .map(|f| f.len())
1810                .unwrap_or(0);
1811            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1812
1813            let c = &mut self.state.candidates[i];
1814            if !exhausted_the_fallback_chain {
1815                c.agent = agent;
1816            }
1817            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1818            c.stat = stat;
1819            c.files = files;
1820            c.commits = commits;
1821            c.duration_ms = duration;
1822            c.empty = commits == 0 || patch.trim().is_empty();
1823            // An agent that failed but still produced a committed change stays
1824            // in the running: the patch is what gets judged, not the exit code.
1825            c.failed = match failed {
1826                Some(_) if c.empty => failed,
1827                _ => None,
1828            };
1829            // Only an empty candidate can be a verified no-op: a claim next
1830            // to a real patch is not what the marker is for, and `c.failed`
1831            // being `Some` here already implies `verified_claim` was never
1832            // set (see the guard above the match that produced it).
1833            c.verified_noop = if c.empty { verified_claim } else { None };
1834            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1835                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1836                (None, true, Some(_), _) => {
1837                    format!("candidate {label}: no change produced (agent-verified no-op)")
1838                }
1839                (None, true, None, _) => format!("candidate {label}: no change produced"),
1840                (None, false, _, true) => {
1841                    format!(
1842                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1843                    )
1844                }
1845                (None, false, _, false) => {
1846                    format!("candidate {label}: {files} files, {commits} commits")
1847                }
1848            };
1849            self.state.event("implement", note);
1850            self.state.save()?;
1851        }
1852
1853        self.after_implement()
1854    }
1855
1856    /// Ask again, once, for work a CLI did and then failed to hand over.
1857    ///
1858    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1859    /// status with an empty response and a usage report showing output tokens,
1860    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1861    /// seven minutes and 14,267 output tokens that arrived as an empty
1862    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1863    ///
1864    /// Two conditions, and both matter:
1865    ///
1866    /// - **Only when the tree is untouched.** Often the agent has already
1867    ///   written its files and only the closing message was lost; the rescue
1868    ///   commit below picks that up and there is nothing to ask for. Re-asking
1869    ///   then would pay for a second implementation of work already on disk.
1870    /// - **Once.** A CLI that drops one stream can drop the next, and this
1871    ///   node is the most expensive in the graph.
1872    ///
1873    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1874    /// dropped reply still carried its `conversation_id`, so the seat is asked
1875    /// to finish what it was doing rather than sent the whole task again. It
1876    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
1877    /// node's - for the same reason a re-ranked judge does: restating finished
1878    /// work is not the work.
1879    ///
1880    /// Unlike a quota this is worth retrying at all: a rate limit fails the
1881    /// same way until it resets, while an abandoned conversation is still
1882    /// there to be picked up.
1883    async fn resume_undelivered(
1884        &mut self,
1885        results: &mut [(usize, SeatState, AgentOutcome)],
1886        sent: &[SeatJob],
1887        prompts: &Prompts,
1888        run_id: &str,
1889    ) {
1890        for (wi, seat, out) in results.iter_mut() {
1891            let Some(dropped) = (match &*out {
1892                AgentOutcome::Dropped(o) => o.dropped.clone(),
1893                _ => None,
1894            }) else {
1895                continue;
1896            };
1897            let Some(job) = sent.get(*wi) else { continue };
1898            // Already on disk? Then only the closing message was lost.
1899            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1900                self.state.event(
1901                    "implement",
1902                    format!(
1903                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1904                         work is in the tree",
1905                        seat.key, dropped.output_tokens, dropped.why
1906                    ),
1907                );
1908                continue;
1909            }
1910            // The re-ask only makes sense as a resume: `resume_after_drop`
1911            // says nothing about the task, trusting the seat to still hold it.
1912            // Without a session to resume — sessions disabled, or this CLI's
1913            // drop shape happened not to carry a session id — that prompt
1914            // would open a brand-new conversation with no context at all,
1915            // which is worse than leaving this as the ordinary failure it
1916            // already is.
1917            if !has_context(&job.spec, seat, job.sessions) {
1918                self.state.event(
1919                    "implement",
1920                    format!(
1921                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
1922                         is no session left to resume",
1923                        seat.key, dropped.output_tokens, dropped.why
1924                    ),
1925                );
1926                continue;
1927            }
1928            self.state.event(
1929                "implement",
1930                format!(
1931                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
1932                     conversation",
1933                    seat.key, dropped.output_tokens, dropped.why
1934                ),
1935            );
1936            let mut retry = job.clone();
1937            retry.seat = seat.clone();
1938            retry.prompt = prompt::resume_after_drop(&dropped.why);
1939            retry.timeout = retry_budget(job.timeout, true);
1940            retry.stem = format!("{}-resume", job.stem);
1941            let cache = self.state.config.cache_dir();
1942            let ctx = WaveCtx {
1943                carry_seats: false,
1944                run: run_id,
1945                node: "implement",
1946                prompts,
1947                cache: cache.as_deref(),
1948                round: None,
1949            };
1950            let (resumed_seat, resumed) =
1951                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
1952            *seat = resumed_seat;
1953            *out = resumed;
1954        }
1955    }
1956
1957    /// Fall an implement seat through to the next untried agent in the
1958    /// implementer roster when it lost to quota — or, since the handover was
1959    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
1960    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
1961    /// quota path itself being unchanged — instead of leaving the
1962    /// seat's loss final the moment one agent's account runs dry.
1963    ///
1964    /// Solo runs (`graph.candidates = 1`, `daemon::apply_solo`'s forced shape)
1965    /// are the motivating case: `Config::resolve_roles`'s `implementers`
1966    /// truncates to the single slot rotation picked, so a solo task whose one
1967    /// implementer hits quota mid-run used to have nothing else to try. This
1968    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
1969    /// unrotated roster — which is the only place the *other* candidates in
1970    /// the machine's roster still exist once `implementers` has been cut down
1971    /// to size.
1972    ///
1973    /// Walks forward from just past the seat's own original position in the
1974    /// roster, never wrapping back to the front: a later candidate slot (say
1975    /// `beta`, the roster's second entry) must fall through to the *next*
1976    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
1977    /// almost certainly a different candidate's own agent already — and once
1978    /// the roster's tail is exhausted there is nothing left to fall through
1979    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
1980    /// whole [`AgentSpec`]: a roster with the same id named twice must not
1981    /// let this retry that id forever. The loop keeps falling through until
1982    /// an attempt lands something other than `Quota` or the roster's tail
1983    /// runs out of untried ids, at which point the seat is left exactly as
1984    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
1985    /// `QuotaLoss` recorded, the candidate failed/empty.
1986    ///
1987    /// `sent` is taken mutably and updated with the fallback agent's spec:
1988    /// `resume_unconfirmed_commands`, which runs after this and also reads
1989    /// `sent`, must see whichever agent actually ended up answering the seat
1990    /// — reading the stale, original spec there would check session
1991    /// eligibility against the wrong CLI and could hand a fallback agent's
1992    /// session id to the agent that just lost the seat to quota.
1993    ///
1994    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
1995    /// — `self.seat` only reuses state when the agent id is unchanged, so
1996    /// handing it a different id already gets this for free. Reusing the old
1997    /// seat would resume a different CLI's session as if it were a
1998    /// continuation of this one.
1999    ///
2000    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2001    /// a quota loss cuts an agent off mid-turn, so anything already in the
2002    /// tree is unfinished work, not a completed candidate a re-ask would pay
2003    /// for twice. A dirty tree is rescued into a commit first (the same
2004    /// neutral-identity rescue `implement`'s own outcome loop gives every
2005    /// candidate) so the next agent starts clean.
2006    ///
2007    /// The new agent gets the implementer's full prompt and full
2008    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2009    /// it has no session and no context, and is implementing the task from
2010    /// nothing, unlike a resumed drop which is only restating work already
2011    /// done.
2012    ///
2013    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2014    /// `implement` event, never into `self.state.quota` — that is what
2015    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2016    /// go unspent, and a seat that ultimately recovered on its second or
2017    /// third agent is not the stalled panel that check exists to catch. Only
2018    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2019    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2020    /// outcome loop already has — this helper never pushes to it itself.
2021    async fn resume_seat_handovers(
2022        &mut self,
2023        results: &mut [(usize, SeatState, AgentOutcome)],
2024        sent: &mut [SeatJob],
2025        prompts: &Prompts,
2026        run_id: &str,
2027    ) {
2028        let instruction = seeded_instruction(&self.state);
2029        let language = self.state.config.graph.language.clone();
2030        let brief = self
2031            .state
2032            .advice
2033            .as_ref()
2034            .and_then(|a| a.synthesis.as_deref())
2035            .map(str::to_owned);
2036        let attachments = self.state.attachments.clone();
2037        for (wi, seat, out) in results.iter_mut() {
2038            // Who holds the other candidate seats of this wave right now
2039            // (earlier handovers already written back to `sent`).
2040            let others: BTreeSet<String> = sent
2041                .iter()
2042                .enumerate()
2043                .filter(|(j, _)| j != wi)
2044                .map(|(_, j)| j.spec.id.clone())
2045                .collect();
2046            let Some(job) = sent.get_mut(*wi) else {
2047                continue;
2048            };
2049            // Where the seat's own original agent sits in the roster — the
2050            // fallback walk starts just past here, never at the front, so a
2051            // later candidate slot's quota loss does not fall back onto an
2052            // earlier slot's own agent.
2053            let start = self
2054                .roles
2055                .implementer_roster
2056                .iter()
2057                .position(|s| s.id == job.spec.id)
2058                .unwrap_or(0);
2059            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2060            let mut fallback_attempt = 0usize;
2061            let mut prev: Option<FailClass> = None;
2062            while let Some(cur) = FailClass::of(&*out) {
2063                if !should_hand_over(prev.as_ref(), &cur) {
2064                    break;
2065                }
2066                let Some(next) =
2067                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2068                        .cloned()
2069                else {
2070                    break;
2071                };
2072                tried.insert(next.id.clone());
2073                fallback_attempt += 1;
2074
2075                if let Ok(r) = git::rescue_commit(
2076                    &job.cwd,
2077                    &format!(
2078                        "magi: candidate {} (uncommitted work before {} fallback)",
2079                        seat.key,
2080                        if cur == FailClass::Quota {
2081                            "quota"
2082                        } else {
2083                            "handover"
2084                        }
2085                    ),
2086                )
2087                .await
2088                {
2089                    self.state.note_withheld("implement", &r.withheld);
2090                }
2091
2092                record_handover(
2093                    &mut self.state,
2094                    "implement",
2095                    &seat.key,
2096                    &seat.agent,
2097                    &next.id,
2098                    &cur,
2099                    &fail_reason(&*out),
2100                );
2101                prev = Some(cur.clone());
2102
2103                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2104                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2105                // Kept in sync on `sent` itself, not just the local retry: a
2106                // later helper (`resume_unconfirmed_commands`) reads `sent`
2107                // after this one returns and must see whichever agent is now
2108                // occupying the seat, not the one that just quota'd out —
2109                // otherwise it would judge session/continuation eligibility
2110                // by the wrong CLI and could resend a fallback's session id
2111                // to the agent that lost it the seat in the first place.
2112                job.spec = next.clone();
2113                let mut retry = job.clone();
2114                retry.seat = new_seat;
2115                retry.prompt = prompt::implement(
2116                    &instruction,
2117                    &job.cwd.to_string_lossy(),
2118                    &language,
2119                    brief.as_deref(),
2120                    &attachments,
2121                );
2122                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2123                let cache = self.state.config.cache_dir();
2124                let ctx = WaveCtx {
2125                    carry_seats: false,
2126                    run: run_id,
2127                    node: "implement",
2128                    prompts,
2129                    cache: cache.as_deref(),
2130                    round: None,
2131                };
2132                let (fallback_seat, fallback_out) = run_one(
2133                    retry,
2134                    Arc::clone(&self.sem),
2135                    &ctx,
2136                    &mut self.state,
2137                    fallback_attempt,
2138                )
2139                .await;
2140                *seat = fallback_seat;
2141                *out = fallback_out;
2142            }
2143        }
2144    }
2145
2146    /// Ask an implement seat's own CLI to confirm what it started, once, when
2147    /// its reply reported a command whose completion status it never
2148    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2149    /// that does and does not mean.
2150    ///
2151    /// The completion contract this task asks for, extended to `implement`
2152    /// with the same signal `continue_fix_report` reads for the fixer,
2153    /// rather than a keyword search over the reply or a hard requirement on
2154    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2155    /// a candidate's CLI turn ended cleanly while a test run it had started
2156    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2157    /// named in it at all is untouched by this: `commands` is empty, so
2158    /// there is nothing to be unconfirmed.
2159    ///
2160    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2161    /// this is not about recovering edits that might already be on disk, it
2162    /// is about a result the seat itself never vouched for, which resuming
2163    /// asks for regardless of what the tree already holds. Bounded to one
2164    /// attempt for the same reason `resume_undelivered` is — this is the
2165    /// most expensive node in the graph — and a seat that still cannot
2166    /// confirm on that attempt is left as whatever its (possibly still
2167    /// unconfirmed) reply says; this does not invent a new "failed" reason
2168    /// for a candidate that otherwise produced a real, committed change.
2169    async fn resume_unconfirmed_commands(
2170        &mut self,
2171        results: &mut [(usize, SeatState, AgentOutcome)],
2172        sent: &[SeatJob],
2173        prompts: &Prompts,
2174        run_id: &str,
2175    ) {
2176        for (wi, seat, out) in results.iter_mut() {
2177            let AgentOutcome::Ok(o) = &*out else {
2178                continue;
2179            };
2180            if !has_unconfirmed_command(&o.commands) {
2181                continue;
2182            }
2183            let Some(job) = sent.get(*wi) else { continue };
2184            if !has_context(&job.spec, seat, job.sessions) {
2185                self.state.event(
2186                    "implement",
2187                    format!(
2188                        "{}: the reply named a command whose own CLI never confirmed the exit \
2189                         status of, but there is no session left to resume",
2190                        seat.key
2191                    ),
2192                );
2193                continue;
2194            }
2195            self.state.event(
2196                "implement",
2197                format!(
2198                    "{}: the reply named a command whose own CLI never confirmed the exit \
2199                     status of; resuming the conversation",
2200                    seat.key
2201                ),
2202            );
2203            let mut retry = job.clone();
2204            retry.seat = seat.clone();
2205            retry.prompt = prompt::resume_incomplete(
2206                "a command in your last reply had no confirmed exit status",
2207            );
2208            retry.timeout = retry_budget(job.timeout, true);
2209            retry.stem = format!("{}-confirm", job.stem);
2210            let cache = self.state.config.cache_dir();
2211            let ctx = WaveCtx {
2212                carry_seats: false,
2213                run: run_id,
2214                node: "implement",
2215                prompts,
2216                cache: cache.as_deref(),
2217                round: None,
2218            };
2219            let (resumed_seat, resumed) =
2220                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2221            *seat = resumed_seat;
2222            *out = resumed;
2223        }
2224    }
2225
2226    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2227    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2228    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2229    /// that motivated this.
2230    ///
2231    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2232    /// own nudge loop already covers for judge/review/vote seats, and not a
2233    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2234    /// implement seats: here the CLI turn genuinely finished while the node's
2235    /// own work — the fixer's account of what it did — had not. Gated purely
2236    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2237    /// reply, never on any wording in it, so a fixer whose valid, first-try
2238    /// `FixReport` happens to mention having waited on a background test is
2239    /// never resumed — the `Ok(report)` branch at the call site returns
2240    /// before this is ever invoked.
2241    ///
2242    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2243    /// attempt ([`retry_budget`]), nothing attempted once the session is
2244    /// gone, and a quota hit ends the loop immediately rather than retrying a
2245    /// rate limit that fails the same way again.
2246    async fn continue_fix_report(
2247        &mut self,
2248        mut seat: SeatState,
2249        parse_err: String,
2250        job: &SeatJob,
2251        prompts: &Prompts,
2252        run_id: &str,
2253        round: usize,
2254    ) -> (
2255        SeatState,
2256        Option<FixReport>,
2257        Option<String>,
2258        ContinuationRecord,
2259    ) {
2260        let mut last_err = parse_err;
2261        let mut cumulative_wait_ms = 0u64;
2262        let mut attempts = 0usize;
2263        loop {
2264            if !has_context(&job.spec, &seat, job.sessions) {
2265                self.state.event(
2266                    "fix",
2267                    format!(
2268                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2269                         session left to resume into"
2270                    ),
2271                );
2272                let outcome = if attempts == 0 {
2273                    ContinuationOutcome::NoSession
2274                } else {
2275                    ContinuationOutcome::Exhausted
2276                };
2277                return (
2278                    seat,
2279                    None,
2280                    Some(format!("unparsable fix report: {last_err}")),
2281                    ContinuationRecord {
2282                        attempts,
2283                        cumulative_wait_ms,
2284                        outcome,
2285                    },
2286                );
2287            }
2288            if attempts >= MAX_FIX_CONTINUATIONS {
2289                self.state.event(
2290                    "fix",
2291                    format!(
2292                        "round {round}: fixer's reply still had no adoption report after \
2293                         {attempts} continuation(s) ({last_err}); giving up"
2294                    ),
2295                );
2296                return (
2297                    seat,
2298                    None,
2299                    Some(format!(
2300                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2301                    )),
2302                    ContinuationRecord {
2303                        attempts,
2304                        cumulative_wait_ms,
2305                        outcome: ContinuationOutcome::Exhausted,
2306                    },
2307                );
2308            }
2309            attempts += 1;
2310            self.state.event(
2311                "fix",
2312                format!(
2313                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2314                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2315                ),
2316            );
2317            let mut retry = job.clone();
2318            retry.seat = seat.clone();
2319            retry.prompt = prompt::resume_incomplete(&last_err);
2320            retry.timeout = retry_budget(job.timeout, true);
2321            retry.stem = format!("{}-continue{attempts}", job.stem);
2322            let cache = self.state.config.cache_dir();
2323            let ctx = WaveCtx {
2324                carry_seats: false,
2325                run: run_id,
2326                node: "fix",
2327                prompts,
2328                cache: cache.as_deref(),
2329                round: Some(round),
2330            };
2331            let (resumed_seat, resumed_out) = run_one(
2332                retry,
2333                Arc::clone(&self.sem),
2334                &ctx,
2335                &mut self.state,
2336                attempts,
2337            )
2338            .await;
2339            seat = resumed_seat;
2340            match resumed_out {
2341                AgentOutcome::Ok(o) => {
2342                    cumulative_wait_ms += o.duration_ms;
2343                    match verdict::extract_json::<FixReport>(&o.text) {
2344                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2345                            self.state.event(
2346                                "fix",
2347                                format!(
2348                                    "round {round}: fixer's adoption report recovered after \
2349                                     {attempts} continuation(s)"
2350                                ),
2351                            );
2352                            return (
2353                                seat,
2354                                Some(report),
2355                                None,
2356                                ContinuationRecord {
2357                                    attempts,
2358                                    cumulative_wait_ms,
2359                                    outcome: ContinuationOutcome::Resumed,
2360                                },
2361                            );
2362                        }
2363                        // The report parsed, but this same reply's own
2364                        // CommandEvidence — the identical record `state.jobs`
2365                        // renders — names a command whose CLI never
2366                        // confirmed an exit status. Read together, that is
2367                        // not a resolved answer: keep nudging rather than
2368                        // accept a report standing next to a command the
2369                        // seat's own CLI cannot vouch for.
2370                        Ok(_) => {
2371                            last_err = "the reply parsed, but it reported a command whose own CLI \
2372                                 never confirmed an exit status"
2373                                .to_owned();
2374                        }
2375                        Err(e) => last_err = e.to_string(),
2376                    }
2377                }
2378                AgentOutcome::Quota(o) => {
2379                    cumulative_wait_ms += o.duration_ms;
2380                    self.state.quota.push(QuotaLoss {
2381                        seat: seat.key.clone(),
2382                        node: "fix".to_owned(),
2383                        at: Timestamp::now(),
2384                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2385                    });
2386                    self.state.event(
2387                        "fix",
2388                        format!(
2389                            "round {round}: continuation rate limited (quota); not retrying now"
2390                        ),
2391                    );
2392                    return (
2393                        seat,
2394                        None,
2395                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2396                        ContinuationRecord {
2397                            attempts,
2398                            cumulative_wait_ms,
2399                            outcome: ContinuationOutcome::QuotaLost,
2400                        },
2401                    );
2402                }
2403                AgentOutcome::Dropped(o) => {
2404                    cumulative_wait_ms += o.duration_ms;
2405                    let why = o
2406                        .dropped
2407                        .as_ref()
2408                        .map(|d| d.why.as_str())
2409                        .unwrap_or("the CLI ended the stream without delivering its answer");
2410                    last_err = format!("the CLI dropped the stream ({why})");
2411                }
2412                AgentOutcome::Failed(e) => last_err = e,
2413            }
2414        }
2415    }
2416
2417    fn after_implement(&mut self) -> Result<()> {
2418        // Scan every candidate patch once the set is complete.
2419        if self.state.leaks.is_empty() {
2420            let cfg = self.state.config.blind.clone();
2421            let mut leaks = Vec::new();
2422            for c in &self.state.candidates {
2423                let Some(patch) =
2424                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2425                else {
2426                    continue;
2427                };
2428                leaks.extend(blind::scan(
2429                    &format!("candidate {} patch", c.label),
2430                    &patch,
2431                    &cfg.vendor_tokens,
2432                ));
2433            }
2434            if !leaks.is_empty() {
2435                let summary = leaks
2436                    .iter()
2437                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2438                    .collect::<Vec<_>>()
2439                    .join(", ");
2440                match cfg.on_leak {
2441                    LeakPolicy::Fail => {
2442                        self.state.status = RunStatus::Failed;
2443                        self.state
2444                            .event("blind", format!("vendor text in a patch: {summary}"));
2445                        self.state.leaks = leaks;
2446                        self.state.save()?;
2447                        self.settle_questions();
2448                        bail!(
2449                            "blind.on_leak = \"fail\" and vendor text reached a \
2450                             judged patch: {summary}"
2451                        );
2452                    }
2453                    LeakPolicy::Redact => self.state.event(
2454                        "blind",
2455                        format!("redacting vendor text for judging: {summary}"),
2456                    ),
2457                    LeakPolicy::Warn => self.state.event(
2458                        "blind",
2459                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2460                    ),
2461                }
2462                self.state.leaks = leaks;
2463            }
2464        }
2465
2466        if self.state.viable().is_empty() {
2467            if self.state.all_candidates_verified_noop() {
2468                // Every candidate agreed, with evidence the adoption guard
2469                // accepted, that nothing belongs in this worktree. That is
2470                // not the same fact as a candidate that simply failed to
2471                // write anything, and settling it as an ordinary `Failed`
2472                // (see `SCHEMA`'s doc for schema 10) is what let two of
2473                // task 391f's attempts burn a retry each re-discovering the
2474                // same already-landed fix. Terminal either way, so `judge`
2475                // must never run over an empty candidate set — unlike the
2476                // `Failed` branch below this returns `Ok`, not an error:
2477                // nothing here failed.
2478                self.state.status = RunStatus::VerifiedNoop;
2479                self.state.save()?;
2480                self.settle_questions();
2481                return Ok(());
2482            }
2483            self.state.status = RunStatus::Failed;
2484            self.state.save()?;
2485            self.settle_questions();
2486            bail!("no candidate produced a change; nothing to judge");
2487        }
2488        self.state.status = RunStatus::Judging;
2489        self.state.save()?;
2490        Ok(())
2491    }
2492
2493    // --------------------------------------------------------------- judge
2494
2495    async fn judge(&mut self) -> Result<()> {
2496        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2497        // agent files with `magi task add` name the run that paid for it. The
2498        // prompt overlay is cloned alongside it because the waves borrow it
2499        // while `self` is mutably borrowed by the node's own bookkeeping.
2500        let run_id = self.state.id.clone();
2501        let prompts = self.state.config.prompts.clone();
2502        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2503            return Ok(());
2504        }
2505        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2506        if viable.len() == 1 {
2507            // Recorded so this is a one-time event: `judgements` stays empty
2508            // either way, which without this flag is indistinguishable from
2509            // "not yet judged" on the next reentry — and status is left
2510            // untouched, so a later node's conclusion (e.g. `Blocked` after
2511            // the review budget ran out) survives a resume instead of being
2512            // clobbered back to `Judging` by this node running again.
2513            self.state.judge_skipped = true;
2514            self.state.event(
2515                "judge",
2516                format!(
2517                    "only candidate {} produced a change; judging skipped",
2518                    viable[0].label
2519                ),
2520            );
2521            self.state.save()?;
2522            return Ok(());
2523        }
2524        self.state.status = RunStatus::Judging;
2525
2526        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2527        let language = self.state.config.graph.language.clone();
2528        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2529        let sessions = self.state.config.graph.sessions;
2530        let artifacts = agent::artifacts_dir(&self.state.dir());
2531        let root = self.state.worktree_root();
2532        let base_short = short(&self.state.base_commit);
2533
2534        let mut jobs = Vec::new();
2535        let mut orders = Vec::new();
2536        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2537            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2538            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2539            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2540            let seat_key = format!("judge-{}", j + 1);
2541            let seat = self.seat(&seat_key, &spec.id);
2542            jobs.push(SeatJob {
2543                prompt: prompt::judge(
2544                    &self.state.instruction,
2545                    &views,
2546                    self.roles.judges.len(),
2547                    &base_short,
2548                    &language,
2549                ),
2550                spec,
2551                seat,
2552                cwd: root.join(format!("judge-{}", j + 1)),
2553                timeout,
2554                allow_write: false,
2555                sessions,
2556                artifacts: artifacts.clone(),
2557                stem: format!("judge-{}", j + 1),
2558                handover: None,
2559            });
2560        }
2561
2562        self.state.event(
2563            "judge",
2564            format!(
2565                "{} judges ranking {} candidates blind",
2566                jobs.len(),
2567                viable.len()
2568            ),
2569        );
2570        let labels_for_check = labels.clone();
2571        let mut quota_losses = Vec::new();
2572        let cache = self.state.config.cache_dir();
2573        let ctx = WaveCtx {
2574            carry_seats: false,
2575            run: &run_id,
2576            node: "judge",
2577            prompts: &prompts,
2578            cache: cache.as_deref(),
2579            round: None,
2580        };
2581        let results = ask_json_wave::<Ranking>(
2582            jobs,
2583            Arc::clone(&self.sem),
2584            self.state.config.graph.retries,
2585            &self.roles.judge_roster,
2586            &ctx,
2587            &mut quota_losses,
2588            &mut self.state,
2589            &move |r: &Ranking| r.validate(&labels_for_check),
2590        )
2591        .await;
2592        self.state.quota.extend(quota_losses);
2593
2594        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2595            let agent_id = seat.agent.clone();
2596            self.state.seats.insert(seat.key.clone(), seat);
2597            let mut record = Judgement {
2598                judge: j + 1,
2599                seat: format!("judge-{}", j + 1),
2600                agent: agent_id,
2601                ranking: Vec::new(),
2602                reasons: BTreeMap::new(),
2603                confidence: None,
2604                order: orders[j].clone(),
2605                failed: None,
2606                duration_ms: 0,
2607            };
2608            match res {
2609                Ok((ranking, out)) => {
2610                    record.ranking = ranking.normalized();
2611                    record.reasons = ranking.reasons;
2612                    record.confidence = ranking.confidence;
2613                    record.duration_ms = out.duration_ms;
2614                    self.state.event(
2615                        "judge",
2616                        format!(
2617                            "judge {} ranked {}",
2618                            j + 1,
2619                            record.ranking.iter().collect::<String>()
2620                        ),
2621                    );
2622                }
2623                Err(e) => {
2624                    record.failed = Some(e.to_string());
2625                    self.state
2626                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2627                }
2628            }
2629            self.state.judgements.push(record);
2630            self.state.save()?;
2631        }
2632        Ok(())
2633    }
2634
2635    // ---------------------------------------------------------- deliberate
2636
2637    async fn deliberate(&mut self) -> Result<()> {
2638        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2639        // agent files with `magi task add` name the run that paid for it. The
2640        // prompt overlay is cloned alongside it because the waves borrow it
2641        // while `self` is mutably borrowed by the node's own bookkeeping.
2642        let run_id = self.state.id.clone();
2643        let prompts = self.state.config.prompts.clone();
2644        if !self.state.deliberation.is_empty() {
2645            return Ok(());
2646        }
2647        let tops: Vec<char> = self
2648            .state
2649            .judgements
2650            .iter()
2651            .filter_map(|j| j.ranking.first().copied())
2652            .collect();
2653        let rounds = self.state.config.graph.deliberate_rounds;
2654        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2655            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2656                self.state.event(
2657                    "deliberate",
2658                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2659                );
2660            }
2661            self.state.status = RunStatus::Voting;
2662            self.state.save()?;
2663            return Ok(());
2664        }
2665
2666        self.state.status = RunStatus::Deliberating;
2667        self.state.event(
2668            "deliberate",
2669            format!(
2670                "split: first choices were {} — opening {rounds} round(s)",
2671                tops.iter().collect::<String>()
2672            ),
2673        );
2674
2675        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2676        let language = self.state.config.graph.language.clone();
2677        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2678        let sessions = self.state.config.graph.sessions;
2679        let artifacts = agent::artifacts_dir(&self.state.dir());
2680        let root = self.state.worktree_root();
2681        let base_short = short(&self.state.base_commit);
2682
2683        // Judges argue in sequence so that a turn can answer the one before it;
2684        // that is the difference between deliberation and three parallel
2685        // monologues.
2686        for round in 1..=rounds {
2687            let mut turns: Vec<DeliberationTurn> = Vec::new();
2688            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2689                if self.state.judgements[j].failed.is_some() {
2690                    continue;
2691                }
2692                let seat_key = format!("judge-{}", j + 1);
2693                let spec = self.occupant(&seat_key, spec);
2694                let mut seat = self.seat(&seat_key, &spec.id);
2695                let transcript = self.transcript(&turns, j);
2696                let build = |context: Option<&str>| {
2697                    prompt::deliberate(
2698                        &self.state.instruction,
2699                        context,
2700                        &transcript,
2701                        round,
2702                        rounds,
2703                        &language,
2704                    )
2705                };
2706                let block = self.candidate_block(&viable, &base_short);
2707                let full = build(Some(&block));
2708                let text = if has_context(&spec, &seat, sessions) {
2709                    build(None)
2710                } else {
2711                    full.clone()
2712                };
2713                let job = SeatJob {
2714                    spec,
2715                    seat: seat.clone(),
2716                    prompt: text,
2717                    cwd: root.join(format!("judge-{}", j + 1)),
2718                    timeout,
2719                    allow_write: false,
2720                    sessions,
2721                    artifacts: artifacts.clone(),
2722                    stem: format!("delib-{round}-judge-{}", j + 1),
2723                    handover: Some(full),
2724                };
2725                let cache = self.state.config.cache_dir();
2726                let ctx = WaveCtx {
2727                    carry_seats: false,
2728                    run: &run_id,
2729                    node: "deliberate",
2730                    prompts: &prompts,
2731                    cache: cache.as_deref(),
2732                    round: None,
2733                };
2734                // A turn is never nudged (`retries` 0): a failed seat is
2735                // handed to the next roster agent, which gets the full
2736                // context. An empty answer is a turn, not a failure.
2737                let mut losses = Vec::new();
2738                let mut results = ask_wave_with::<String>(
2739                    vec![job],
2740                    Arc::clone(&self.sem),
2741                    0,
2742                    &self.roles.judge_roster,
2743                    &ctx,
2744                    &mut losses,
2745                    &mut self.state,
2746                    &|text: &str| {
2747                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2748                    },
2749                )
2750                .await;
2751                self.state.quota.extend(losses);
2752                let (updated, res, _) = results.pop().expect("one job in, one result out");
2753                seat = updated;
2754                let agent_id = seat.agent.clone();
2755                self.state.seats.insert(seat.key.clone(), seat);
2756                let body = match res {
2757                    Ok((body, _)) => body,
2758                    // Skip the seat; a CLI's raw error JSON is never read as
2759                    // this judge's position.
2760                    Err(e) => {
2761                        self.state
2762                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2763                        continue;
2764                    }
2765                };
2766                let tentative = verdict::extract_json::<Position>(&body)
2767                    .ok()
2768                    .and_then(|p| p.tentative)
2769                    .and_then(|s| s.trim().chars().next())
2770                    .map(|c| c.to_ascii_uppercase());
2771                self.state.event(
2772                    "deliberate",
2773                    format!(
2774                        "round {round}: judge {} now favours {}",
2775                        j + 1,
2776                        tentative.map_or("—".to_owned(), |c| c.to_string())
2777                    ),
2778                );
2779                turns.push(DeliberationTurn {
2780                    judge: j + 1,
2781                    agent: agent_id,
2782                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2783                    tentative,
2784                });
2785            }
2786            self.state
2787                .deliberation
2788                .push(DeliberationRound { round, turns });
2789            self.state.save()?;
2790        }
2791
2792        self.state.status = RunStatus::Voting;
2793        self.state.save()?;
2794        Ok(())
2795    }
2796
2797    // ---------------------------------------------------------------- vote
2798
2799    async fn vote(&mut self) -> Result<()> {
2800        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2801        // agent files with `magi task add` name the run that paid for it. The
2802        // prompt overlay is cloned alongside it because the waves borrow it
2803        // while `self` is mutably borrowed by the node's own bookkeeping.
2804        let run_id = self.state.id.clone();
2805        let prompts = self.state.config.prompts.clone();
2806        if !self.state.votes.is_empty() {
2807            return Ok(());
2808        }
2809        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2810        if viable.len() == 1 {
2811            return Ok(());
2812        }
2813        self.state.status = RunStatus::Voting;
2814
2815        let language = self.state.config.graph.language.clone();
2816        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2817        let sessions = self.state.config.graph.sessions;
2818        let artifacts = agent::artifacts_dir(&self.state.dir());
2819        let root = self.state.worktree_root();
2820        let base_short = short(&self.state.base_commit);
2821        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2822
2823        let mut jobs = Vec::new();
2824        let mut seats_at = Vec::new();
2825        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2826            if self
2827                .state
2828                .judgements
2829                .get(j)
2830                .is_some_and(|r| r.failed.is_some())
2831            {
2832                continue;
2833            }
2834            let seat_key = format!("judge-{}", j + 1);
2835            let spec = self.occupant(&seat_key, spec);
2836            let seat = self.seat(&seat_key, &spec.id);
2837            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2838            let text = if has_context(&spec, &seat, sessions) {
2839                prompt::final_vote(&viable, &language)
2840            } else {
2841                full.clone()
2842            };
2843            jobs.push(SeatJob {
2844                spec,
2845                seat,
2846                prompt: text,
2847                cwd: root.join(format!("judge-{}", j + 1)),
2848                timeout,
2849                allow_write: false,
2850                sessions,
2851                artifacts: artifacts.clone(),
2852                stem: format!("vote-judge-{}", j + 1),
2853                handover: Some(full),
2854            });
2855            seats_at.push(j);
2856        }
2857
2858        self.state.event(
2859            "vote",
2860            format!(
2861                "collecting {} final votes one by one, privately",
2862                jobs.len()
2863            ),
2864        );
2865        let allowed = viable.clone();
2866        let mut quota_losses = Vec::new();
2867        let cache = self.state.config.cache_dir();
2868        let ctx = WaveCtx {
2869            carry_seats: false,
2870            run: &run_id,
2871            node: "vote",
2872            prompts: &prompts,
2873            cache: cache.as_deref(),
2874            round: None,
2875        };
2876        let results = ask_json_wave::<FinalVote>(
2877            jobs,
2878            Arc::clone(&self.sem),
2879            self.state.config.graph.retries,
2880            &self.roles.judge_roster,
2881            &ctx,
2882            &mut quota_losses,
2883            &mut self.state,
2884            &move |v: &FinalVote| match v.label() {
2885                Some(c) if allowed.contains(&c) => Ok(()),
2886                other => bail!("vote {other:?} is not one of {allowed:?}"),
2887            },
2888        )
2889        .await;
2890        self.state.quota.extend(quota_losses);
2891
2892        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2893            let agent_id = seat.agent.clone();
2894            self.state.seats.insert(seat.key.clone(), seat);
2895            let initial = self
2896                .state
2897                .judgements
2898                .get(j)
2899                .and_then(|r| r.ranking.first().copied());
2900            let mut record = VoteRecord {
2901                judge: j + 1,
2902                agent: agent_id,
2903                vote: None,
2904                reason: String::new(),
2905                changed: false,
2906            };
2907            match res {
2908                Ok((v, _)) => {
2909                    record.vote = v.label();
2910                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2911                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2912                    self.state.event(
2913                        "vote",
2914                        format!(
2915                            "judge {} voted {}{}",
2916                            j + 1,
2917                            record.vote.unwrap_or('?'),
2918                            if record.changed { " (changed)" } else { "" }
2919                        ),
2920                    );
2921                }
2922                Err(e) => {
2923                    self.state
2924                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
2925                }
2926            }
2927            self.state.votes.push(record);
2928            self.state.save()?;
2929        }
2930        Ok(())
2931    }
2932
2933    // --------------------------------------------------------------- tally
2934
2935    fn tally(&mut self) -> Result<()> {
2936        if self.state.tally.is_some() {
2937            return Ok(());
2938        }
2939        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2940        let tops: Vec<char> = self
2941            .state
2942            .judgements
2943            .iter()
2944            .filter_map(|j| j.ranking.first().copied())
2945            .collect();
2946        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
2947
2948        // A judge whose private vote failed still counted once, in the initial
2949        // ranking; using it beats discarding a whole seat.
2950        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2951        let mut cast: Vec<char> = Vec::new();
2952        for (i, j) in self.state.judgements.iter().enumerate() {
2953            let vote = self
2954                .state
2955                .votes
2956                .iter()
2957                .find(|v| v.judge == i + 1)
2958                .and_then(|v| v.vote)
2959                .or_else(|| j.ranking.first().copied());
2960            if let Some(v) = vote {
2961                *first_choice.entry(v).or_insert(0) += 1;
2962                cast.push(v);
2963            }
2964        }
2965
2966        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
2967        for j in &self.state.judgements {
2968            let n = j.ranking.len();
2969            for (pos, label) in j.ranking.iter().enumerate() {
2970                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
2971            }
2972        }
2973
2974        let best = first_choice.values().copied().max().unwrap_or(0);
2975        let mut leaders: Vec<char> = first_choice
2976            .iter()
2977            .filter(|(_, v)| **v == best)
2978            .map(|(k, _)| *k)
2979            .collect();
2980        let mut tie_break = None;
2981        if leaders.len() > 1 {
2982            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
2983            let borda_leaders: Vec<char> = leaders
2984                .iter()
2985                .copied()
2986                .filter(|l| borda[l] == top_borda)
2987                .collect();
2988            tie_break = Some(if borda_leaders.len() == 1 {
2989                format!(
2990                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
2991                    leaders.len()
2992                )
2993            } else {
2994                format!(
2995                    "{} way tie on both first-choice votes and Borda points, broken by label order",
2996                    leaders.len()
2997                )
2998            });
2999            leaders = borda_leaders;
3000            leaders.sort_unstable();
3001        }
3002        let winner = *leaders
3003            .first()
3004            .or(viable.first())
3005            .context("no candidate to declare a winner from")?;
3006
3007        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3008        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3009        let deliberated = !self.state.deliberation.is_empty();
3010
3011        // Whose verdict is this? A rate-limited seat is absent even if it
3012        // ranked before the limit hit, so presence is measured against the
3013        // recorded losses, not just "did a ranking ever appear".
3014        let quota_seats: std::collections::BTreeSet<&str> =
3015            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3016        let mut present = 0usize;
3017        for (i, j) in self.state.judgements.iter().enumerate() {
3018            if quota_seats.contains(j.seat.as_str()) {
3019                continue;
3020            }
3021            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3022            let voted = self
3023                .state
3024                .votes
3025                .iter()
3026                .any(|v| v.judge == i + 1 && v.vote.is_some());
3027            if ranked || voted {
3028                present += 1;
3029            }
3030        }
3031        // Strict majority of the configured panel. A bare majority is real
3032        // signal we can act on, while a minority verdict must never stand in
3033        // for a healthy one. A one-candidate run needs no panel at all, and
3034        // `judges` stays `0` rather than the roster size a panel that never
3035        // sat would otherwise be credited with.
3036        let needs_quorum = viable.len() > 1;
3037        let judges_total = if needs_quorum {
3038            self.roles.judges.len()
3039        } else {
3040            0
3041        };
3042        let quorum = if needs_quorum {
3043            judges_total / 2 + 1
3044        } else {
3045            0
3046        };
3047        let met_quorum = !needs_quorum || present >= quorum;
3048        let uncontested = (!needs_quorum).then(|| {
3049            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3050        });
3051
3052        self.state.event(
3053            "tally",
3054            match &uncontested {
3055                Some(reason) => format!("winner {winner} — {reason}"),
3056                None => format!(
3057                    "winner {winner} — votes {} | initial {} | {} changed | \
3058                     {present}/{judges_total} judges{}",
3059                    first_choice
3060                        .iter()
3061                        .map(|(k, v)| format!("{k}:{v}"))
3062                        .collect::<Vec<_>>()
3063                        .join(" "),
3064                    if unanimous_initial {
3065                        "unanimous"
3066                    } else {
3067                        "split"
3068                    },
3069                    changed_votes,
3070                    if met_quorum {
3071                        String::new()
3072                    } else {
3073                        format!(" — below quorum ({quorum} required)")
3074                    },
3075                ),
3076            },
3077        );
3078        if !met_quorum {
3079            self.state.event(
3080                "stall",
3081                format!(
3082                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3083                     the run stops here, resumable"
3084                ),
3085            );
3086        }
3087        self.state.tally = Some(Tally {
3088            first_choice,
3089            borda,
3090            winner,
3091            rankings: tops.len(),
3092            unanimous_initial,
3093            deliberated,
3094            changed_votes,
3095            unanimous_final,
3096            tie_break,
3097            judges: judges_total,
3098            present,
3099            quorum,
3100            met_quorum,
3101            uncontested,
3102        });
3103        self.state.status = if met_quorum {
3104            RunStatus::Reviewing
3105        } else {
3106            RunStatus::Stalled
3107        };
3108        self.state.save()?;
3109        Ok(())
3110    }
3111
3112    // ------------------------------------------------------------- recover
3113
3114    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3115    /// resumed toward completion once the transient cause clears.
3116    ///
3117    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3118    /// it toward the quorum, which is exactly the set of seats whose absence
3119    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3120    /// not depend on which node happened to hit the limit), or an ordinary
3121    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3122    /// seat is never disturbed.
3123    ///
3124    /// A seat that now answers with a usable ranking is "recovered": its
3125    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3126    /// `tally` counts it present again), and its vote re-collected. A seat that
3127    /// still fails keeps its loss and stays absent.
3128    ///
3129    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3130    /// to review/gate/merge), `false` when it is still below quorum (the run
3131    /// stays `Stalled`, still resumable for a later retry).
3132    #[allow(clippy::too_many_lines)]
3133    async fn recover_stall(&mut self) -> Result<bool> {
3134        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3135        // agent files with `magi task add` name the run that paid for it. The
3136        // prompt overlay is cloned alongside it because the waves borrow it
3137        // while `self` is mutably borrowed by the node's own bookkeeping.
3138        let run_id = self.state.id.clone();
3139        let prompts = self.state.config.prompts.clone();
3140        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3141        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3142        // is what keeps a non-quota collapse — or a quota loss recorded at the
3143        // deliberate node — from being a permanent dead-end on `--resume`.
3144        let quota_seats: BTreeSet<&str> =
3145            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3146        let absent: Vec<String> = self
3147            .state
3148            .judgements
3149            .iter()
3150            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3151            .map(|j| j.seat.clone())
3152            .collect();
3153        if absent.is_empty() {
3154            return Ok(false);
3155        }
3156        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3157        if viable.len() <= 1 {
3158            return Ok(false);
3159        }
3160        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3161        let language = self.state.config.graph.language.clone();
3162        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3163        let sessions = self.state.config.graph.sessions;
3164        let artifacts = agent::artifacts_dir(&self.state.dir());
3165        let root = self.state.worktree_root();
3166        let base_short = short(&self.state.base_commit);
3167        let candidates: Vec<Candidate> = viable.clone();
3168
3169        // Map each absent seat key to its 0-based position in `roles.judges`.
3170        let mut positions: Vec<usize> = absent
3171            .iter()
3172            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3173            .collect();
3174        if positions.is_empty() {
3175            return Ok(false);
3176        }
3177        positions.sort_unstable();
3178        positions.dedup();
3179
3180        // Re-rank the lost seats, one blind prompt each.
3181        let mut judge_jobs = Vec::new();
3182        for &j in &positions {
3183            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3184            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3185            let seat_key = format!("judge-{}", j + 1);
3186            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3187            let seat = self.seat(&seat_key, &spec.id);
3188            judge_jobs.push(SeatJob {
3189                spec,
3190                seat,
3191                prompt: prompt::judge(
3192                    &self.state.instruction,
3193                    &views,
3194                    self.roles.judges.len(),
3195                    &base_short,
3196                    &language,
3197                ),
3198                cwd: root.join(seat_key),
3199                timeout,
3200                allow_write: false,
3201                sessions,
3202                artifacts: artifacts.clone(),
3203                stem: format!("judge-{}-recover", j + 1),
3204                handover: None,
3205            });
3206        }
3207
3208        let labels_for_check = labels.clone();
3209        let mut judge_losses = Vec::new();
3210        let retries = self.state.config.graph.retries;
3211        let cache = self.state.config.cache_dir();
3212        let ctx = WaveCtx {
3213            carry_seats: false,
3214            run: &run_id,
3215            node: "judge",
3216            prompts: &prompts,
3217            cache: cache.as_deref(),
3218            round: None,
3219        };
3220        let results = ask_json_wave::<Ranking>(
3221            judge_jobs,
3222            Arc::clone(&self.sem),
3223            retries,
3224            &self.roles.judge_roster,
3225            &ctx,
3226            &mut judge_losses,
3227            &mut self.state,
3228            &move |r: &Ranking| r.validate(&labels_for_check),
3229        )
3230        .await;
3231
3232        // Refresh the judgement of every seat that ranked again.
3233        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3234        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3235            let agent_id = seat.agent.clone();
3236            self.state.seats.insert(seat.key.clone(), seat);
3237            let record = &mut self.state.judgements[j];
3238            match res {
3239                Ok((ranking, out)) => {
3240                    record.agent = agent_id;
3241                    record.ranking = ranking.normalized();
3242                    record.reasons = ranking.reasons;
3243                    record.confidence = ranking.confidence;
3244                    record.failed = None;
3245                    record.duration_ms = out.duration_ms;
3246                    recovered.insert(j);
3247                    self.state.event(
3248                        "recover",
3249                        format!("judge {} ranked again after the limit", j + 1),
3250                    );
3251                }
3252                Err(e) => {
3253                    self.state
3254                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3255                }
3256            }
3257        }
3258
3259        // Re-ask the votes of the seats that recovered a ranking.
3260        let mut vote_jobs = Vec::new();
3261        let mut vote_pos: Vec<usize> = Vec::new();
3262        for &j in &recovered {
3263            let seat_key = format!("judge-{}", j + 1);
3264            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3265            let seat = self.seat(&seat_key, &spec.id);
3266            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3267            let text = if has_context(&spec, &seat, sessions) {
3268                prompt::final_vote(&labels, &language)
3269            } else {
3270                full.clone()
3271            };
3272            vote_jobs.push(SeatJob {
3273                spec,
3274                seat,
3275                prompt: text,
3276                cwd: root.join(seat_key),
3277                timeout,
3278                allow_write: false,
3279                sessions,
3280                artifacts: artifacts.clone(),
3281                stem: format!("vote-judge-{}-recover", j + 1),
3282                handover: Some(full),
3283            });
3284            vote_pos.push(j);
3285        }
3286        let allowed = labels.clone();
3287        let mut vote_losses = Vec::new();
3288        let vote_retries = self.state.config.graph.retries;
3289        let vote_cache = self.state.config.cache_dir();
3290        let ctx = WaveCtx {
3291            carry_seats: false,
3292            run: &run_id,
3293            node: "vote",
3294            prompts: &prompts,
3295            cache: vote_cache.as_deref(),
3296            round: None,
3297        };
3298        let votes = ask_json_wave::<FinalVote>(
3299            vote_jobs,
3300            Arc::clone(&self.sem),
3301            vote_retries,
3302            &self.roles.judge_roster,
3303            &ctx,
3304            &mut vote_losses,
3305            &mut self.state,
3306            &move |v: &FinalVote| match v.label() {
3307                Some(c) if allowed.contains(&c) => Ok(()),
3308                other => bail!("vote {other:?} is not one of {allowed:?}"),
3309            },
3310        )
3311        .await;
3312        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3313            let agent_id = seat.agent.clone();
3314            self.state.seats.insert(seat.key.clone(), seat);
3315            match res {
3316                Ok((v, _)) => {
3317                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3318                        rec.vote = v.label();
3319                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3320                    } else {
3321                        self.state.votes.push(VoteRecord {
3322                            judge: j + 1,
3323                            agent: agent_id,
3324                            vote: v.label(),
3325                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3326                            changed: false,
3327                        });
3328                    }
3329                    self.state.event(
3330                        "recover",
3331                        format!("judge {} voted again after the limit", j + 1),
3332                    );
3333                }
3334                Err(e) => {
3335                    self.state
3336                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3337                }
3338            }
3339        }
3340
3341        // A seat that ranked again is present even if its re-vote failed —
3342        // `tally` falls back to the initial ranking's first choice — so clear
3343        // its quota loss. Seats that still fail keep theirs and stay absent.
3344        let recovered_keys: BTreeSet<String> = recovered
3345            .iter()
3346            .map(|&j| format!("judge-{}", j + 1))
3347            .collect();
3348        self.state
3349            .quota
3350            .retain(|q| !recovered_keys.contains(&q.seat));
3351        // A seat that hit the limit again is a fresh loss, not the old one:
3352        // replace the stale entry so the history stays one-per-seat and the
3353        // daemon can tell this attempt's loss from a previous session's.
3354        for loss in judge_losses.into_iter().chain(vote_losses) {
3355            if recovered_keys.contains(&loss.seat) {
3356                continue;
3357            }
3358            self.state.quota.retain(|q| q.seat != loss.seat);
3359            self.state.quota.push(loss);
3360        }
3361
3362        // Recompute the verdict from the refreshed panel.
3363        self.state.tally = None;
3364        self.tally()?;
3365        Ok(self
3366            .state
3367            .tally
3368            .as_ref()
3369            .map(|t| t.met_quorum)
3370            .unwrap_or(false))
3371    }
3372
3373    // ----------------------------------------------------------------- fold
3374
3375    async fn fold_losers(&mut self) -> Result<()> {
3376        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3377            return Ok(());
3378        };
3379        let repo = self.state.repo.clone();
3380        let mut folded = Vec::new();
3381        for i in 0..self.state.candidates.len() {
3382            let c = &self.state.candidates[i];
3383            if c.label == winner || c.folded {
3384                continue;
3385            }
3386            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3387            git::worktree_remove(&repo, &wt).await.ok();
3388            git::branch_delete(&repo, &branch).await.ok();
3389            self.state.candidates[i].folded = true;
3390            folded.push(label.to_string());
3391        }
3392        // The judges are finished; their checkouts are pure cost from here.
3393        let root = self.state.worktree_root();
3394        for j in 1..=self.roles.judges.len() {
3395            let wt = root.join(format!("judge-{j}"));
3396            if wt.exists() {
3397                git::worktree_remove(&repo, &wt).await.ok();
3398            }
3399        }
3400        // The design-deliberation stage is finished by the time a tally
3401        // exists — same reasoning as the judges above.
3402        if self.state.config.graph.advise {
3403            for k in 1..=self.state.config.graph.advisors {
3404                let wt = root.join(format!("advisor-{k}"));
3405                if wt.exists() {
3406                    git::worktree_remove(&repo, &wt).await.ok();
3407                }
3408            }
3409        }
3410        if !folded.is_empty() {
3411            self.state
3412                .event("fold", format!("folded candidates {}", folded.join(", ")));
3413            self.state.save()?;
3414        }
3415        Ok(())
3416    }
3417
3418    // ------------------------------------------------------------ base sync
3419
3420    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3421    /// anything verifies it.
3422    ///
3423    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3424    /// read whatever is checked out in the winner's worktree. Left alone that
3425    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3426    /// it when the run *branched* - and a run takes long enough that the base
3427    /// has usually moved by the time it gets here. A gate that ran there
3428    /// answers "green on the commit this run started from", not "green on
3429    /// what is about to land", and the difference showed up three times in
3430    /// one day as a green run whose merge would have reverted a file another
3431    /// pull request had already landed.
3432    ///
3433    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3434    /// `land::Step::Rebase` calls, rather than a second implementation of the
3435    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3436    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3437    /// doc warns about ("two answers to a question nobody notices until a
3438    /// diff is wrong").
3439    ///
3440    /// A conflict is not the end of the road: the standing rebase is handed
3441    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3442    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3443    /// land). Once it finishes, review and the gate run as usual on the
3444    /// rebased tree, which is where a breakage the new base caused is caught
3445    /// by the ordinary gate-fix round. magi resolves nothing itself.
3446    ///
3447    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3448    /// `state.base_sync.attempts`, is how many times the base is *rebased
3449    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3450    /// *conflict* was given to a fixer. When the fixer cannot finish the
3451    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3452    /// what was tried (rounds spent, paths still conflicted) and the branch
3453    /// and worktree stay exactly as they were - untouched, for a person to
3454    /// look at - which is also what makes re-entering this function
3455    /// afterwards a no-op instead of a second attempt at the same wall. A
3456    /// push failure ends the same way.
3457    async fn sync_to_base(&mut self) -> Result<()> {
3458        if self.state.status == RunStatus::AlreadyInBase {
3459            return Ok(());
3460        }
3461        let conflicted = self
3462            .state
3463            .base_sync
3464            .as_ref()
3465            .is_some_and(|s| s.conflict.is_some());
3466        let Some(winner) = self.state.winner().cloned() else {
3467            return Ok(());
3468        };
3469
3470        let repo = self.state.repo.clone();
3471        let remote = self.state.config.merge.remote.clone();
3472        let base_branch = self.state.base_branch.clone();
3473        let tracking = format!("{remote}/{base_branch}");
3474
3475        git::fetch(&repo, &remote, &base_branch).await.ok();
3476        // No network, or the remote never had this branch: the run already
3477        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
3478        // otherwise), and one that got this far is not blocked by a fetch
3479        // that fails now. It just has no newer tip to compare against.
3480        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3481            return Ok(());
3482        };
3483
3484        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3485        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3486        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3487
3488        // Before any rebase, and before a recorded conflict is honoured: a
3489        // branch whose change reached the base under other commit ids has
3490        // nothing to rebase and nothing to conflict with, and a run that
3491        // already stopped on that phantom conflict recovers here on resume.
3492        // `behind == 0` with head == tip is a branch the base has since taken
3493        // in whole, whether or not a conflict was ever recorded: the ancestry
3494        // proof must still run (`classify` ignores a head still on the start
3495        // commit).
3496        if (behind > 0 || conflicted || head == tip)
3497            && self
3498                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3499                .await?
3500        {
3501            return Ok(());
3502        }
3503        if conflicted {
3504            return Ok(());
3505        }
3506
3507        if behind == 0 {
3508            // A fixer-finished rebase moves the branch ref before the
3509            // winner's worktree is told (`sync_to_head` below). A run that
3510            // died in between resumes here with `behind == 0` and a tree still
3511            // holding the pre-rebase files, which review and the gate would
3512            // then read. That state is exactly: HEAD moved off the tip the
3513            // rebase started from, yet the tree is still identical to that
3514            // tip. A tree with edits of its own differs from it, so nothing
3515            // is thrown away.
3516            if let Some(from) = self
3517                .state
3518                .rebase_fixes
3519                .iter()
3520                .rev()
3521                .find_map(|r| r.from.clone())
3522                && from != head
3523                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3524                    .await
3525                    .is_ok_and(|o| o.ok())
3526            {
3527                git::sync_to_head(&winner.worktree).await?;
3528            }
3529            // An earlier attempt may have rebased the branch locally and died
3530            // before pushing it (only the fresh-rebase arm below pushes).
3531            // Publish it now, so the plain push at PR time is not refused as
3532            // a non-fast-forward. A run already holding a recorded conflict
3533            // never reaches here; that case is out of scope.
3534            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3535            if let Some(why) = &conflict {
3536                self.state.status = RunStatus::Blocked;
3537                self.state.event("land", why.clone());
3538            }
3539            self.state.base_sync = Some(BaseSync {
3540                tip,
3541                behind: 0,
3542                attempts,
3543                conflict,
3544                already_in: None,
3545            });
3546            self.state.save()?;
3547            return Ok(());
3548        }
3549
3550        if attempts >= BASE_SYNC_ROUNDS {
3551            let why = format!(
3552                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3553                 rebase(s); rebasing again would only race it",
3554                winner.branch
3555            );
3556            self.state.status = RunStatus::Blocked;
3557            self.state.base_sync = Some(BaseSync {
3558                tip,
3559                behind,
3560                attempts,
3561                conflict: Some(why.clone()),
3562                already_in: None,
3563            });
3564            self.state.event("land", why);
3565            self.state.save()?;
3566            return Ok(());
3567        }
3568
3569        self.state.event(
3570            "land",
3571            format!(
3572                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3573                winner.branch
3574            ),
3575        );
3576        self.state.save()?;
3577
3578        // The remote's copy of the branch, read now and only if the fetch
3579        // really succeeded (a stale tracking ref must never pin a lease). It is
3580        // pushed over after a rebase only when it is a commit this branch
3581        // already contains, by ancestry or by patch (an earlier rebase of ours
3582        // that never reached the remote): anything else is somebody else's work.
3583        let branch_tracking = format!("{remote}/{}", winner.branch);
3584        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3585        let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3586            git::rev_parse(&repo, &branch_tracking).await.ok()
3587        } else {
3588            None
3589        };
3590        // A remote tip this branch does not contain is somebody else's work:
3591        // rebasing would leave a local tip that can never be pushed. Stop
3592        // before touching anything and say so.
3593        if let Some(theirs) = &remote_tip
3594            && !git::is_ancestor(&repo, theirs, &head).await
3595            && !crate::reconcile::origin_missing(&repo, &head, theirs)
3596                .await
3597                .is_ok_and(|missing| missing.is_empty())
3598        {
3599            let why = format!(
3600                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3601                 them",
3602                short(theirs),
3603                winner.branch
3604            );
3605            self.state.status = RunStatus::Blocked;
3606            self.state.base_sync = Some(BaseSync {
3607                tip,
3608                behind,
3609                attempts,
3610                conflict: Some(why.clone()),
3611                already_in: None,
3612            });
3613            self.state.event("land", why);
3614            self.state.save()?;
3615            return Ok(());
3616        }
3617
3618        let scratch = self.state.dir().join("base-sync");
3619        let rebased = match crate::rebase::rebase_with_fixer(
3620            &mut self.state,
3621            &scratch,
3622            &winner.branch,
3623            &tracking,
3624        )
3625        .await
3626        {
3627            Ok(crate::rebase::Rebased::Applied) => Ok(None),
3628            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3629            Err(e) => Err(e),
3630        };
3631        let attempts = attempts + 1;
3632        match rebased {
3633            Ok(None) => {
3634                // The branch ref moved, but a worktree that already had it
3635                // checked out (the winner's) was not told; sync its index and
3636                // files before anything reads them.
3637                git::sync_to_head(&winner.worktree).await?;
3638                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3639                let mut conflict = None;
3640                if let Some(pinned) = &remote_tip {
3641                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3642                    match pushed {
3643                        Ok(o) if o.ok() => self.state.event(
3644                            "land",
3645                            format!("pushed rebased {} to {remote}", winner.branch),
3646                        ),
3647                        Ok(o) => {
3648                            conflict = Some(format!(
3649                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
3650                                winner.branch,
3651                                short(pinned),
3652                                o.stderr.chars().take(600).collect::<String>()
3653                            ));
3654                        }
3655                        Err(e) => {
3656                            conflict = Some(format!(
3657                                "rebased {} locally but could not push it: {e:#}",
3658                                winner.branch
3659                            ));
3660                        }
3661                    }
3662                }
3663                if let Some(why) = &conflict {
3664                    self.state.status = RunStatus::Blocked;
3665                    self.state.event("land", why.clone());
3666                }
3667                self.state.base_sync = Some(BaseSync {
3668                    tip: tip.clone(),
3669                    behind: 0,
3670                    attempts,
3671                    conflict,
3672                    already_in: None,
3673                });
3674                self.state
3675                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
3676            }
3677            Ok(Some(conflict)) => {
3678                let why = format!(
3679                    "{} conflicts with {tracking} and did not rebase: {}",
3680                    winner.branch,
3681                    conflict.chars().take(600).collect::<String>()
3682                );
3683                self.state.status = RunStatus::Blocked;
3684                self.state.base_sync = Some(BaseSync {
3685                    tip,
3686                    behind,
3687                    attempts,
3688                    conflict: Some(why.clone()),
3689                    already_in: None,
3690                });
3691                self.state.event("land", why);
3692            }
3693            Err(e) => {
3694                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3695                self.state.status = RunStatus::Blocked;
3696                self.state.base_sync = Some(BaseSync {
3697                    tip,
3698                    behind,
3699                    attempts,
3700                    conflict: Some(why.clone()),
3701                    already_in: None,
3702                });
3703                self.state.event("land", why);
3704            }
3705        }
3706        self.state.save()?;
3707        Ok(())
3708    }
3709
3710    /// Push a branch an earlier attempt rebased locally but never published,
3711    /// pinned to the remote tip read right after a successful fetch. Returns
3712    /// the reason when the run must stop; `None` when there was nothing to do
3713    /// (no remote copy, the same tip, or a remote copy this branch already
3714    /// contains, which the PR-time push fast-forwards) or the push succeeded.
3715    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3716        let repo = self.state.repo.clone();
3717        let remote = self.state.config.merge.remote.clone();
3718        let fetched = git::fetch(&repo, &remote, branch).await;
3719        if !matches!(&fetched, Ok(o) if o.ok()) {
3720            return None;
3721        }
3722        let branch_tracking = format!("{remote}/{branch}");
3723        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3724        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3725            return None;
3726        }
3727        if !crate::reconcile::origin_missing(&repo, head, &theirs)
3728            .await
3729            .is_ok_and(|missing| missing.is_empty())
3730        {
3731            return Some(format!(
3732                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3733                 them",
3734                short(&theirs)
3735            ));
3736        }
3737        match git::push_pinned(&repo, &remote, branch, &theirs).await {
3738            Ok(o) if o.ok() => {
3739                self.state
3740                    .event("land", format!("pushed rebased {branch} to {remote}"));
3741                None
3742            }
3743            Ok(o) => Some(format!(
3744                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3745                 someone may have pushed): {}",
3746                short(&theirs),
3747                o.stderr.chars().take(600).collect::<String>()
3748            )),
3749            Err(e) => Some(format!(
3750                "{branch} is rebased locally but could not be pushed: {e:#}"
3751            )),
3752        }
3753    }
3754
3755    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
3756    /// is already on `tip` under other commit ids ([`crate::already`]); returns
3757    /// whether it did.
3758    ///
3759    /// Checked only when the base is ahead of the branch. A failing check is
3760    /// "not proven" - the ordinary rebase path then decides - never a reason to
3761    /// stop the run.
3762    ///
3763    /// The remote copy of the branch is held to the same standard as the local
3764    /// one: if it carries a tip this worktree does not, that tip must itself be
3765    /// proven in the base, or nothing is settled (a pull request would
3766    /// otherwise be closed over commits nobody checked). The pull request is
3767    /// closed *before* the terminal status is saved; if that fails for a
3768    /// reason other than a refusal (no network, a `gh` error) the run is left
3769    /// `Blocked` with the reason as its conflict, which a resume retries -
3770    /// the same recovery a phantom conflict gets.
3771    async fn settle_already_in(
3772        &mut self,
3773        branch: &str,
3774        tip: &str,
3775        head: &str,
3776        attempts: usize,
3777        behind: usize,
3778    ) -> Result<bool> {
3779        let repo = self.state.repo.clone();
3780        let remote = self.state.config.merge.remote.clone();
3781        let start = self.state.base_commit.clone();
3782        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3783            Ok(Some(e)) => e,
3784            Ok(None) => return Ok(false),
3785            Err(e) => {
3786                tracing::warn!("already-in-base check for {branch}: {e:#}");
3787                return Ok(false);
3788            }
3789        };
3790        let mut verified = vec![head.to_owned()];
3791        let fetched = git::fetch(&repo, &remote, branch).await;
3792        if matches!(&fetched, Ok(o) if o.ok())
3793            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3794            && theirs != head
3795        {
3796            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3797                Ok(Some(_)) => verified.push(theirs),
3798                _ => return Ok(false),
3799            }
3800        }
3801        let base_branch = self.state.base_branch.clone();
3802        let message = format!(
3803            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3804             land",
3805            evidence.names(),
3806            evidence.proof.as_str()
3807        );
3808        let closed =
3809            crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3810        match closed {
3811            Ok(Ok(url)) => self
3812                .state
3813                .event("land", format!("closed {url}: superseded on {base_branch}")),
3814            Ok(Err(why)) => self
3815                .state
3816                .event("land", format!("did not close a pull request: {why}")),
3817            Err(e) => {
3818                let why = format!(
3819                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
3820                     not be closed ({e:#}); resume to retry"
3821                );
3822                self.state.status = RunStatus::Blocked;
3823                self.state.base_sync = Some(BaseSync {
3824                    tip: tip.to_owned(),
3825                    behind,
3826                    attempts,
3827                    conflict: Some(why.clone()),
3828                    already_in: None,
3829                });
3830                self.state.event("land", why);
3831                self.state.save()?;
3832                return Ok(true);
3833            }
3834        }
3835        self.state.status = RunStatus::AlreadyInBase;
3836        self.state.base_sync = Some(BaseSync {
3837            tip: tip.to_owned(),
3838            behind,
3839            attempts,
3840            conflict: None,
3841            already_in: Some(evidence),
3842        });
3843        self.state.event("land", message);
3844        self.state.save()?;
3845        self.settle_questions();
3846        Ok(true)
3847    }
3848
3849    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
3850    /// last landed the winner on, once it has run, else the commit the run
3851    /// branched from.
3852    ///
3853    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
3854    /// and `vote` all happen before there is a winner to rebase, so they
3855    /// compare every candidate against the branch point on purpose, and a
3856    /// base that moves after they are already done cannot change an answer
3857    /// they already gave.
3858    fn landing_base(&self) -> String {
3859        self.state
3860            .base_sync
3861            .as_ref()
3862            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3863    }
3864
3865    // ------------------------------------------------------- operator fix
3866
3867    /// Route specific, already-recorded review findings to a fixer for a
3868    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
3869    /// entry point.
3870    ///
3871    /// Distinct from `review_loop`'s own fix step in three ways: it never
3872    /// runs a reviewer wave, it never spends review-round budget, and what
3873    /// happened is recorded as an [`OperatorFixRequest`] appended to
3874    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
3875    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
3876    /// and vote must never be rewritten to look like a manufactured blocking
3877    /// verdict.
3878    ///
3879    /// Only meaningful once review has actually concluded: `Ready` (handed
3880    /// off with findings still open, or simply concluded clean while minor
3881    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
3882    /// gate failed). Everything else is refused: a run still in progress
3883    /// should simply be resumed, and a `Merged` run's branch has already
3884    /// landed — reopening *this* run's own record cannot change that, so the
3885    /// answer there is a fresh `magi review <branch>`.
3886    ///
3887    /// A real commit here re-verifies through a fresh, ordinary review-only
3888    /// run on the same branch ([`Self::review`]) rather than reopening this
3889    /// run's own `review_loop`: once any round in this run's history went
3890    /// clean, `review_conclusion` treats that as permanent by design (the
3891    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
3892    /// way to force one more genuine reviewer wave out of *this* run without
3893    /// either rewriting history or weakening that guarantee for every other
3894    /// caller. A review-only run costs nothing extra — no implementation, no
3895    /// judging, no vote — and exercises the exact same review → verify →
3896    /// gate → (human) merge path, unmodified.
3897    pub async fn fix_selected(
3898        &mut self,
3899        ids: &[String],
3900        reason: &str,
3901        allow_stale: bool,
3902    ) -> Result<()> {
3903        let reason = reason.trim();
3904        if reason.is_empty() {
3905            bail!("a fix request needs a reason — that is the operator's own record of why");
3906        }
3907        if ids.is_empty() {
3908            bail!("no finding id given");
3909        }
3910        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3911            bail!(
3912                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3913                 has already concluded — can be given a targeted fix. A run still \
3914                 in progress should simply be resumed; a `merged` run's branch has \
3915                 already landed, so its answer is a fresh `magi review <branch>`, \
3916                 not reopening this run's own record",
3917                self.state.id,
3918                self.state.status.as_str()
3919            );
3920        }
3921        let Some(winner) = self.state.winner().cloned() else {
3922            bail!("run {} has no winning candidate to fix", self.state.id);
3923        };
3924        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
3925            bail!(
3926                "branch `{}` no longer exists; this run cannot be extended",
3927                winner.branch
3928            );
3929        }
3930        let home = crate::run::home();
3931        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
3932            bail!(
3933                "run {} is currently being worked on by another magi process",
3934                self.state.id
3935            );
3936        }
3937        // Held for the rest of this call, including the follow-up review
3938        // below: two `magi fix` invocations against the same run must not
3939        // both reach the worktree manipulation further down, which would
3940        // otherwise race to remove and recreate the same directory — see
3941        // [`FixClaim`]'s own doc.
3942        let _claim = FixClaim::acquire(&self.state.dir())?;
3943
3944        // Resolve every id before spending anything — an unknown id refuses
3945        // the whole request rather than silently dropping it — and dedup
3946        // while keeping the operator's own order.
3947        let mut seen = BTreeSet::new();
3948        let mut findings = Vec::new();
3949        let mut missing = Vec::new();
3950        for id in ids {
3951            if !seen.insert(id.clone()) {
3952                continue;
3953            }
3954            match self.state.finding(id) {
3955                Some((round, rec, f)) => findings.push(OperatorFixFinding {
3956                    id: f.id.clone(),
3957                    severity: f.severity,
3958                    reviewer_vote: rec.vote,
3959                    round: round.round,
3960                    round_head: round.head.clone(),
3961                    reviewer: rec.reviewer,
3962                    agent: rec.agent.clone(),
3963                    file: f.file.clone(),
3964                    line: f.line,
3965                    title: f.title.clone(),
3966                    detail: f.detail.clone(),
3967                    outcome: OperatorFixOutcome::Pending,
3968                }),
3969                None => missing.push(id.clone()),
3970            }
3971        }
3972        if !missing.is_empty() {
3973            bail!(
3974                "unknown finding id(s): {}; nothing was changed",
3975                missing.join(", ")
3976            );
3977        }
3978
3979        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
3980        let stale_details: Vec<(String, String)> = findings
3981            .iter()
3982            .filter(|f| f.round_head != head_at_request)
3983            .map(|f| (f.id.clone(), f.round_head.clone()))
3984            .collect();
3985        let stale = !stale_details.is_empty();
3986        if stale && !allow_stale {
3987            bail!(
3988                "the branch has moved since some finding(s) were raised — {} — now \
3989                 at {}; pass --allow-stale to fix anyway, or re-run review first",
3990                stale_details
3991                    .iter()
3992                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
3993                    .collect::<Vec<_>>()
3994                    .join(", "),
3995                short(&head_at_request)
3996            );
3997        }
3998
3999        let request = OperatorFixRequest {
4000            requested_at: Timestamp::now(),
4001            reason: reason.to_owned(),
4002            findings,
4003            head_at_request: head_at_request.clone(),
4004            allow_stale,
4005            stale,
4006            fix: None,
4007            result_head: None,
4008            follow_up_review_run: None,
4009        };
4010        self.state.event(
4011            "fix",
4012            format!(
4013                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4014                request.findings.len(),
4015                request
4016                    .findings
4017                    .iter()
4018                    .map(|f| f.id.as_str())
4019                    .collect::<Vec<_>>()
4020                    .join(", "),
4021            ),
4022        );
4023        // Recorded now, before any worktree work or the fixer call itself —
4024        // and re-saved at each checkpoint below: a crash at any point after
4025        // this (mid fixer call, mid follow-up review) must not lose the fact
4026        // that this was requested, for which findings, and why. Everything
4027        // past this point reads and writes through `request_index` rather
4028        // than a local variable, since `request` itself is moved here.
4029        self.state.operator_fixes.push(request);
4030        self.state.save()?;
4031        let request_index = self.state.operator_fixes.len() - 1;
4032
4033        // A fresh, dedicated worktree for this one call, never the winner's
4034        // own worktree in place: that one may already be gone (folded away),
4035        // and reusing it in place would leave the branch checked out there
4036        // when the follow-up review below tries to check it out again. Freed
4037        // immediately after, either way — but only once confirmed clean:
4038        // `worktree_remove` is a `git worktree remove --force`, which would
4039        // otherwise discard uncommitted work left there by the operator or
4040        // another process before this had a chance to even look at it.
4041        if winner.worktree.exists() {
4042            // Lockfiles a rescue commit withheld stay untracked on purpose and
4043            // are already recorded; they are not the operator's work to protect.
4044            let dirty = git::git(
4045                &winner.worktree,
4046                &["status", "--porcelain", "--untracked-files=all"],
4047            )
4048            .await?;
4049            let only_withheld = dirty.lines().all(|l| {
4050                l.strip_prefix("?? ")
4051                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4052            });
4053            if !only_withheld {
4054                bail!(
4055                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4056                     discard them first",
4057                    winner.worktree.display()
4058                );
4059            }
4060            git::worktree_remove(&self.state.repo, &winner.worktree)
4061                .await
4062                .ok();
4063        }
4064        let fix_worktree = self.state.worktree_root().join("operator-fix");
4065        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4066        git::git(
4067            &self.state.repo,
4068            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4069        )
4070        .await
4071        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4072        if !git::is_clean(&fix_worktree).await? {
4073            git::worktree_remove(&self.state.repo, &fix_worktree)
4074                .await
4075                .ok();
4076            bail!(
4077                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4078                winner.branch
4079            );
4080        }
4081
4082        let run_id = self.state.id.clone();
4083        let prompts = self.state.config.prompts.clone();
4084        let language = self.state.config.graph.language.clone();
4085        let sessions = self.state.config.graph.sessions;
4086        let artifacts = agent::artifacts_dir(&self.state.dir());
4087        let (fix_spec, fix_seat_key) = match &self.roles.fixer {
4088            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4089            _ => (
4090                self.state
4091                    .config
4092                    .agent(&winner.agent)
4093                    .cloned()
4094                    .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4095                format!("impl-{}", winner.label),
4096            ),
4097        };
4098        let seat = self.seat(&fix_seat_key, &fix_spec.id);
4099        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4100            .findings
4101            .iter()
4102            .map(|f| Finding {
4103                id: f.id.clone(),
4104                severity: f.severity,
4105                file: f.file.clone(),
4106                line: f.line,
4107                title: f.title.clone(),
4108                detail: f.detail.clone(),
4109            })
4110            .collect();
4111        let job = SeatJob {
4112            prompt: prompt::operator_fix(
4113                &self.state.instruction,
4114                &finding_list,
4115                reason,
4116                &stale_details,
4117                &head_at_request,
4118                &language,
4119            ),
4120            spec: fix_spec.clone(),
4121            seat,
4122            cwd: fix_worktree.clone(),
4123            timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
4124            allow_write: true,
4125            sessions,
4126            artifacts: artifacts.clone(),
4127            stem: "operator-fix".to_owned(),
4128            handover: None,
4129        };
4130        let cache = self.state.config.cache_dir();
4131        let ctx = WaveCtx {
4132            carry_seats: false,
4133            run: &run_id,
4134            node: "fix",
4135            prompts: &prompts,
4136            cache: cache.as_deref(),
4137            round: None,
4138        };
4139        let (seat, out) =
4140            run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4141        let agent_id = seat.agent.clone();
4142
4143        let mut fix = FixRecord {
4144            agent: agent_id,
4145            addressed: Vec::new(),
4146            rejected: Vec::new(),
4147            notes: String::new(),
4148            committed: false,
4149            failed: None,
4150            duration_ms: 0,
4151            continuation: None,
4152        };
4153        let mut final_seat = seat.clone();
4154        match out {
4155            AgentOutcome::Ok(o) => {
4156                fix.duration_ms = o.duration_ms;
4157                let parsed = verdict::extract_json::<FixReport>(&o.text);
4158                let incomplete_reason = match &parsed {
4159                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4160                        "the reply parsed, but it reported a command whose own CLI \
4161                         never confirmed an exit status"
4162                            .to_owned(),
4163                    ),
4164                    Ok(_) => None,
4165                    Err(e) => Some(e.to_string()),
4166                };
4167                match incomplete_reason {
4168                    None => {
4169                        let report = parsed.expect("checked Ok above");
4170                        fix.addressed = report.addressed;
4171                        fix.rejected = report.rejected;
4172                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4173                    }
4174                    Some(reason) => {
4175                        let (resumed_seat, resolved, failure, cont) = self
4176                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4177                            .await;
4178                        fix.duration_ms += cont.cumulative_wait_ms;
4179                        fix.continuation = Some(cont);
4180                        final_seat = resumed_seat;
4181                        match resolved {
4182                            Some(report) => {
4183                                fix.addressed = report.addressed;
4184                                fix.rejected = report.rejected;
4185                                fix.notes =
4186                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4187                            }
4188                            None => fix.failed = failure,
4189                        }
4190                    }
4191                }
4192            }
4193            AgentOutcome::Dropped(o) => {
4194                fix.duration_ms = o.duration_ms;
4195                let why = o
4196                    .dropped
4197                    .as_ref()
4198                    .map(|d| d.why.as_str())
4199                    .unwrap_or("the CLI ended the stream without delivering its answer");
4200                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4201            }
4202            AgentOutcome::Quota(o) => {
4203                self.state.quota.push(QuotaLoss {
4204                    seat: final_seat.key.clone(),
4205                    node: "fix".to_owned(),
4206                    at: Timestamp::now(),
4207                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4208                });
4209                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4210            }
4211            AgentOutcome::Failed(e) => fix.failed = Some(e),
4212        }
4213        if fix.continuation.is_none() {
4214            fix.continuation = Some(ContinuationRecord::not_needed());
4215        }
4216        self.state.seats.insert(final_seat.key.clone(), final_seat);
4217
4218        let rescue_message = format!(
4219            "magi: operator-selected fix ({}) (uncommitted work)",
4220            self.state.operator_fixes[request_index]
4221                .findings
4222                .iter()
4223                .map(|f| f.id.as_str())
4224                .collect::<Vec<_>>()
4225                .join(", ")
4226        );
4227        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4228            self.state.note_withheld("fix", &r.withheld);
4229        }
4230        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4231        fix.committed = after != head_at_request;
4232        git::worktree_remove(&self.state.repo, &fix_worktree)
4233            .await
4234            .ok();
4235
4236        self.state.event(
4237            "fix",
4238            match &fix.failed {
4239                Some(reason) => format!(
4240                    "operator fix: adoption report was lost ({reason}); {}",
4241                    if fix.committed {
4242                        "committed"
4243                    } else {
4244                        "NO new commit"
4245                    }
4246                ),
4247                None => format!(
4248                    "operator fix: {} addressed, {} rejected, {}",
4249                    fix.addressed.len(),
4250                    fix.rejected.len(),
4251                    if fix.committed {
4252                        "committed"
4253                    } else {
4254                        "NO new commit"
4255                    }
4256                ),
4257            },
4258        );
4259
4260        // Every selected finding gets an outcome — never left `Pending` once
4261        // the fixer's own turn is over. A report that never came back at all
4262        // marks every one of them `Unreported`, not silently "not addressed":
4263        // quota, a dropped stream, or an exhausted continuation are gaps in
4264        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4265        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4266        for f in &mut self.state.operator_fixes[request_index].findings {
4267            f.outcome = if fix.failed.is_some() {
4268                OperatorFixOutcome::Unreported
4269            } else if fix.addressed.contains(&f.id) {
4270                OperatorFixOutcome::Addressed
4271            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4272                OperatorFixOutcome::Rejected { why: r.why.clone() }
4273            } else {
4274                OperatorFixOutcome::Unreported
4275            };
4276        }
4277
4278        let committed = fix.committed;
4279        if committed {
4280            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4281        }
4282        self.state.operator_fixes[request_index].fix = Some(fix);
4283        // Saved again now that the fixer's own outcome is final, on top of
4284        // the save right after the request was first pushed above.
4285        self.state.save()?;
4286
4287        if committed {
4288            self.state.event(
4289                "fix",
4290                format!(
4291                    "operator fix committed {}; opening a follow-up review-only run",
4292                    short(&after)
4293                ),
4294            );
4295            // The operator asked for the fix, and the follow-up serves whatever
4296            // task the run it follows served.
4297            let origin =
4298                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4299            match Self::review(
4300                &self.state.repo,
4301                &winner.branch,
4302                self.state.config.clone(),
4303                origin,
4304            )
4305            .await
4306            {
4307                Ok(mut follow_up) => {
4308                    follow_up.state.event(
4309                        "start",
4310                        format!(
4311                            "requested by an operator fix on run {} for finding(s) {}",
4312                            self.state.id,
4313                            self.state.operator_fixes[request_index]
4314                                .findings
4315                                .iter()
4316                                .map(|f| f.id.as_str())
4317                                .collect::<Vec<_>>()
4318                                .join(", "),
4319                        ),
4320                    );
4321                    follow_up.state.save()?;
4322                    let follow_up_id = follow_up.state.id.clone();
4323                    // The follow-up is a run like any other: it belongs to the
4324                    // task of the run it follows, or to one filed for it.
4325                    let adopted = match crate::direct::adopt(
4326                        &follow_up.state,
4327                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4328                    ) {
4329                        Ok(a) => a,
4330                        Err(e) => {
4331                            // An ownerless run must not spend agent calls; it
4332                            // stays saved, and `magi run --resume` adopts it.
4333                            self.state.event(
4334                                "fix",
4335                                format!(
4336                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4337                                ),
4338                            );
4339                            self.state.save()?;
4340                            return Ok(());
4341                        }
4342                    };
4343                    let executed = follow_up.execute().await;
4344                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4345                    if let Some(a) = adopted {
4346                        a.finish(&follow_up.state, executed);
4347                    }
4348                    if let Some(e) = failure {
4349                        self.state.event(
4350                            "fix",
4351                            format!(
4352                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4353                            ),
4354                        );
4355                    }
4356                    self.state.operator_fixes[request_index].follow_up_review_run =
4357                        Some(follow_up_id);
4358                }
4359                Err(e) => {
4360                    self.state.event(
4361                        "fix",
4362                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4363                    );
4364                }
4365            }
4366            self.state.save()?;
4367        }
4368
4369        Ok(())
4370    }
4371
4372    // --------------------------------------------------------------- review
4373
4374    /// The agent and seat key that fix the winner's tree: the configured
4375    /// fixer, else the winner's own implementer seat, whose conversation
4376    /// continues now that the competition is over. Shared by the review loop
4377    /// and the gate-fix round so both talk to the same seat.
4378    fn fixer_spec(&self, winner: &Candidate) -> (AgentSpec, String) {
4379        match &self.roles.fixer {
4380            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
4381            _ => (
4382                self.state
4383                    .config
4384                    .agent(&winner.agent)
4385                    .cloned()
4386                    .unwrap_or_else(|_| self.roles.implementers[winner.index].clone()),
4387                format!("impl-{}", winner.label),
4388            ),
4389        }
4390    }
4391
4392    async fn review_loop(&mut self) -> Result<()> {
4393        // A base that would not rebase is a person's decision, not a review
4394        // round: nothing here would change the answer, and reviewers and a
4395        // fixer would be spending real budget on a tree that cannot land
4396        // regardless of what they find.
4397        if self
4398            .state
4399            .base_sync
4400            .as_ref()
4401            .is_some_and(|s| s.conflict.is_some())
4402        {
4403            return Ok(());
4404        }
4405        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4406        // agent files with `magi task add` name the run that paid for it. The
4407        // prompt overlay is cloned alongside it because the waves borrow it
4408        // while `self` is mutably borrowed by the node's own bookkeeping.
4409        let run_id = self.state.id.clone();
4410        let prompts = self.state.config.prompts.clone();
4411        let Some(winner) = self.state.winner().cloned() else {
4412            return Ok(());
4413        };
4414        let max_rounds = self.state.config.graph.review_rounds;
4415        // A clean round, an exhausted round budget, or a stalled tree (see
4416        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4417        // they are recorded — recomputed here, not read off `status`, so a
4418        // reentry into a run that already stopped restates the identical
4419        // verdict instead of silently handing back whatever an earlier node
4420        // in this same walk clobbered `status` to (a solo-candidate
4421        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4422        // below runs an empty range once the budget is spent, and would
4423        // otherwise fall through without touching `status` at all.
4424        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4425            // A reentry after a crash between the last round's save and
4426            // `stop_reviewing` reaches the hand-off here, not there.
4427            if status == RunStatus::Gating {
4428                self.record_contested_handoff();
4429            }
4430            self.state.status = status;
4431            self.state.save()?;
4432            return Ok(());
4433        }
4434        self.state.status = RunStatus::Reviewing;
4435        // A last recorded round whose own verification never resolved
4436        // (`ResourceBlocked` — the shared build cache, not the patch) is
4437        // never a concluded round, whatever the round budget says: starting
4438        // a fresh round on top of it would spend a whole new reviewer wave
4439        // re-reading an unchanged patch instead of just retrying the one
4440        // check that actually needs it, and once the budget is spent the
4441        // loop below has nothing left to do at all (its range is empty).
4442        // Retry that check directly instead, exactly the same retry
4443        // `stop_reviewing` already does for its own catch-up case.
4444        if self
4445            .state
4446            .reviews
4447            .last()
4448            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4449        {
4450            let shell = self.state.config.shell();
4451            return self
4452                .stop_reviewing(
4453                    "the last round's own verification never resolved",
4454                    &shell,
4455                    &winner.worktree,
4456                )
4457                .await;
4458        }
4459
4460        let repo = self.state.repo.clone();
4461        let root = self.state.worktree_root();
4462        let language = self.state.config.graph.language.clone();
4463        let sessions = self.state.config.graph.sessions;
4464        let artifacts = agent::artifacts_dir(&self.state.dir());
4465        let base = self.landing_base();
4466        let base_short = short(&base);
4467        let reviewers = self.roles.reviewers.clone();
4468        let shell = self.state.config.shell();
4469
4470        for round in (self.state.reviews.len() + 1)..=max_rounds {
4471            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4472            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4473            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4474            // The prior round's own record, already persisted — never a
4475            // hand-carried variable of just its failing output: that is
4476            // exactly what let a round's e2e result drift out of sync with
4477            // which commit it was actually about (see `SCHEMA`'s doc for
4478            // schema 8). Judged against `head`, the commit reviewers are
4479            // about to look at now, so the summary always reads as "an
4480            // earlier head" here — this round's own patch has not been
4481            // checked yet.
4482            let prev_verification = self
4483                .state
4484                .reviews
4485                .last()
4486                .and_then(|r| r.verification_summary(&head));
4487
4488            // Each reviewer gets its own detached checkout of exactly this
4489            // commit: nobody can perturb the winner's tree, and the fixer can
4490            // keep working without racing a reviewer.
4491            let mut jobs = Vec::new();
4492            for (r, spec) in reviewers.iter().cloned().enumerate() {
4493                let wt = root.join(format!("review-{}", r + 1));
4494                if wt.exists() {
4495                    git::reset_detached(&wt, &head).await?;
4496                } else {
4497                    git::worktree_add_detached(&repo, &wt, &head).await?;
4498                }
4499                let seat_key = format!("review-{}", r + 1);
4500                // The seat starts the round on whoever answered it last, not
4501                // on the agent the spec names, so a failure is not re-paid.
4502                let spec = pick_start_spec(
4503                    &self.roles.reviewer_roster,
4504                    spec,
4505                    self.state.seat_history.get(&seat_key),
4506                );
4507                let seat = self.seat(&seat_key, &spec.id);
4508                jobs.push(SeatJob {
4509                    prompt: prompt::review(&prompt::ReviewCtx {
4510                        instruction: &self.state.instruction,
4511                        branch: &winner.branch,
4512                        base_short: &base_short,
4513                        stat: &stat,
4514                        patch: &patch,
4515                        verification: prev_verification.as_ref(),
4516                        reviewers: reviewers.len(),
4517                        round,
4518                        rounds: max_rounds,
4519                        // A review-only run has no rankings, so nothing
4520                        // competed for this patch and the reviewer is told so.
4521                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4522                        lens: Lens::for_seat(r),
4523                        language: &language,
4524                    }),
4525                    spec,
4526                    seat,
4527                    cwd: wt,
4528                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4529                    allow_write: false,
4530                    sessions,
4531                    artifacts: artifacts.clone(),
4532                    stem: format!("review-{round}-{}", r + 1),
4533                    handover: None,
4534                });
4535            }
4536
4537            self.state.event(
4538                "review",
4539                format!(
4540                    "round {round}: {} reviewers on {}",
4541                    jobs.len(),
4542                    short(&head)
4543                ),
4544            );
4545            let mut quota_losses = Vec::new();
4546            let review_retries = self.state.config.graph.retries;
4547            let review_cache = self.state.config.cache_dir();
4548            let ctx = WaveCtx {
4549                carry_seats: true,
4550                run: &run_id,
4551                node: "review",
4552                prompts: &prompts,
4553                cache: review_cache.as_deref(),
4554                round: Some(round),
4555            };
4556            let results = ask_json_wave::<Review>(
4557                jobs,
4558                Arc::clone(&self.sem),
4559                review_retries,
4560                &self.roles.reviewer_roster,
4561                &ctx,
4562                &mut quota_losses,
4563                &mut self.state,
4564                &|_: &Review| Ok(()),
4565            )
4566            .await;
4567            // Counted before the move below: how many of *this* round's
4568            // reviewer seats were lost to their own rate limit, as opposed to
4569            // a crash, a timeout, or unparsable output — see `round_is_clean`.
4570            let round_quota_missing = quota_losses.len();
4571            self.state.quota.extend(quota_losses);
4572
4573            let mut records = Vec::new();
4574            let mut all_findings = Vec::new();
4575            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4576                let agent_id = seat.agent.clone();
4577                self.state.seats.insert(seat.key.clone(), seat);
4578                let mut record = ReviewRecord {
4579                    reviewer: r + 1,
4580                    agent: agent_id,
4581                    summary: String::new(),
4582                    findings: Vec::new(),
4583                    vote: None,
4584                    failed: None,
4585                    duration_ms: 0,
4586                    // Set for both outcomes: `failed: Some(_)` with
4587                    // `attempts > 0` is a seat every retry still lost, not a
4588                    // recovered one — only `failed: None` with `attempts > 0`
4589                    // reads as "answered after a nudge" (see this field's own
4590                    // doc).
4591                    attempts,
4592                };
4593                match res {
4594                    Ok((review, out)) => {
4595                        // Sanitized here, at the point every other piece of
4596                        // agent prose in this file is (candidate summaries,
4597                        // deliberation turns, vote reasons): a reviewer's own
4598                        // words are the one thing about it that could name
4599                        // it, and reconsideration below broadcasts this same
4600                        // summary and these same findings to every other
4601                        // seat on the panel.
4602                        record.summary =
4603                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
4604                        record.vote = Some(review.vote);
4605                        record.duration_ms = out.duration_ms;
4606                        for (n, mut f) in review.findings.into_iter().enumerate() {
4607                            // ids are magi's, never the agent's: the fixer's
4608                            // adoption report is keyed by them.
4609                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4610                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4611                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4612                            // `file` is agent-supplied prose too, never
4613                            // checked against the real tree — the same
4614                            // exposure `title`/`detail` above have, just in
4615                            // a field easy to forget because it looks like a
4616                            // path rather than free text.
4617                            f.file = f
4618                                .file
4619                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4620                            all_findings.push(f.clone());
4621                            record.findings.push(f);
4622                        }
4623                        self.state.event(
4624                            "review",
4625                            format!(
4626                                "round {round}: reviewer {} voted {} with {} finding(s)",
4627                                r + 1,
4628                                review.vote.label(),
4629                                record.findings.len()
4630                            ),
4631                        );
4632                    }
4633                    Err(e) => {
4634                        record.failed = Some(e.to_string());
4635                        self.state.event(
4636                            "review",
4637                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4638                        );
4639                    }
4640                }
4641                records.push(record);
4642            }
4643
4644            // Tally the round's votes and, if they split, spend the one
4645            // round of reconsideration the split -> deliberate -> revote
4646            // shape `judge`/`vote` use for the panel, sized down to what a
4647            // read-only review round can afford: one round, and a revote
4648            // rather than an argument, because the panel already wrote its
4649            // reasoning down as findings the first time around.
4650            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4651            let vote_split =
4652                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4653            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4654            if vote_split {
4655                self.state.event(
4656                    "review",
4657                    format!(
4658                        "round {round}: votes split ({}) — one round of reconsideration",
4659                        initial_votes
4660                            .iter()
4661                            .map(|v| v.label())
4662                            .collect::<Vec<_>>()
4663                            .join(", ")
4664                    ),
4665                );
4666                // Seats read every seat's findings and votes, still numbered
4667                // and never named — the same anonymity `review` itself keeps.
4668                let panel: Vec<ReviewSeatReport<'_>> = records
4669                    .iter()
4670                    .filter_map(|r| {
4671                        r.vote.map(|vote| ReviewSeatReport {
4672                            reviewer: r.reviewer,
4673                            vote,
4674                            summary: &r.summary,
4675                            findings: &r.findings,
4676                        })
4677                    })
4678                    .collect();
4679
4680                let mut jobs = Vec::new();
4681                let mut seats_at = Vec::new();
4682                for (r, spec) in reviewers.iter().cloned().enumerate() {
4683                    // A seat with no initial vote has nothing to reconsider
4684                    // from and stays absent, the same as it stayed absent
4685                    // from `panel` above.
4686                    if records[r].vote.is_none() {
4687                        continue;
4688                    }
4689                    let wt = root.join(format!("review-{}", r + 1));
4690                    let seat_key = format!("review-{}", r + 1);
4691                    let spec = self.occupant(&seat_key, spec);
4692                    let seat = self.seat(&seat_key, &spec.id);
4693                    // A seat with no live session has already forgotten the
4694                    // initial review's prompt — restate the patch it is
4695                    // voting on, the same as `deliberate`/`vote` do for a
4696                    // judge in the same position.
4697                    // The panel already carries this seat's own review and
4698                    // vote, so restating the patch makes the prompt whole for
4699                    // a seat handed to another agent.
4700                    let build = |with_patch: bool| {
4701                        prompt::review_reconsider(&ReviewReconsiderCtx {
4702                            instruction: &self.state.instruction,
4703                            reviewer: r + 1,
4704                            lens: Lens::for_seat(r),
4705                            panel: &panel,
4706                            patch: with_patch.then_some(ReviewPatch {
4707                                branch: &winner.branch,
4708                                base_short: &base_short,
4709                                stat: &stat,
4710                                patch: &patch,
4711                            }),
4712                            round,
4713                            rounds: max_rounds,
4714                            language: &language,
4715                        })
4716                    };
4717                    let full = build(true);
4718                    let prompt = if has_context(&spec, &seat, sessions) {
4719                        build(false)
4720                    } else {
4721                        full.clone()
4722                    };
4723                    jobs.push(SeatJob {
4724                        prompt,
4725                        spec,
4726                        seat,
4727                        cwd: wt,
4728                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4729                        allow_write: false,
4730                        sessions,
4731                        artifacts: artifacts.clone(),
4732                        stem: format!("review-{round}-reconsider-{}", r + 1),
4733                        handover: Some(full),
4734                    });
4735                    seats_at.push(r);
4736                }
4737
4738                let mut recon_quota_losses = Vec::new();
4739                let recon_cache = self.state.config.cache_dir();
4740                let recon_ctx = WaveCtx {
4741                    carry_seats: true,
4742                    run: &run_id,
4743                    node: "review",
4744                    prompts: &prompts,
4745                    cache: recon_cache.as_deref(),
4746                    round: Some(round),
4747                };
4748                let recon_results = ask_json_wave::<ReviewRevote>(
4749                    jobs,
4750                    Arc::clone(&self.sem),
4751                    review_retries,
4752                    &self.roles.reviewer_roster,
4753                    &recon_ctx,
4754                    &mut recon_quota_losses,
4755                    &mut self.state,
4756                    &|_: &ReviewRevote| Ok(()),
4757                )
4758                .await;
4759                self.state.quota.extend(recon_quota_losses);
4760
4761                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4762                    let agent_id = seat.agent.clone();
4763                    self.state.seats.insert(seat.key.clone(), seat);
4764                    let mut rec = ReviewRevoteRecord {
4765                        reviewer: r + 1,
4766                        agent: agent_id,
4767                        vote: None,
4768                        reason: String::new(),
4769                        failed: None,
4770                    };
4771                    match res {
4772                        Ok((rv, _)) => {
4773                            rec.vote = Some(rv.vote);
4774                            rec.reason =
4775                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4776                            self.state.event(
4777                                "review",
4778                                format!(
4779                                    "round {round}: reviewer {} revoted {}",
4780                                    r + 1,
4781                                    rv.vote.label()
4782                                ),
4783                            );
4784                        }
4785                        Err(e) => {
4786                            rec.failed = Some(e.to_string());
4787                            self.state.event(
4788                                "review",
4789                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4790                            );
4791                        }
4792                    }
4793                    reconsideration.push(rec);
4794                }
4795            } else if initial_votes.len() > 1 {
4796                self.state.event(
4797                    "review",
4798                    format!(
4799                        "round {round}: votes agreed ({}) — no reconsideration",
4800                        initial_votes[0].label()
4801                    ),
4802                );
4803            }
4804
4805            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4806            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4807            // A round that already has a blocking finding and a round left to
4808            // try is going back to the fixer no matter what `verify.e2e`
4809            // says, so running it first only spends the loop's slowest step
4810            // (minutes, for a Rust repo's full test suite) on a head about
4811            // to be rewritten. Deferred, never skipped: `verify.e2e` still
4812            // runs once a round has no blocking findings left (see
4813            // `round_is_clean`, which a deferred — empty — `e2e` can never
4814            // satisfy since `blocking` is nonzero whenever this branch is
4815            // taken), and `stop_reviewing` forces a real run before it will
4816            // ever read a deferred round as green.
4817            let defer_e2e =
4818                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4819            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4820                let reason =
4821                    format!("{blocking} blocking finding(s) already required a fix this round");
4822                self.state.event(
4823                    "verify",
4824                    format!(
4825                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4826                         {}); it will run once a round has none left",
4827                        short(&head)
4828                    ),
4829                );
4830                (Vec::new(), false, true, Some(reason))
4831            } else {
4832                let e2e_commands = self.state.config.verify.e2e.clone();
4833                let cache_dir = self.state.config.cache_dir();
4834                let context = format!("round {round}");
4835                let (e2e, verify_retried) = with_cache_lease(
4836                    &mut self.state,
4837                    cache_dir.as_deref(),
4838                    "e2e",
4839                    "e2e",
4840                    &winner.worktree,
4841                    &head,
4842                    verify_timeout,
4843                    &context,
4844                    |state, budget| {
4845                        let shell = shell.clone();
4846                        let e2e_commands = e2e_commands.clone();
4847                        let worktree = winner.worktree.clone();
4848                        let context = context.clone();
4849                        async move {
4850                            run_e2e_with_retry(
4851                                state,
4852                                &shell,
4853                                &e2e_commands,
4854                                &worktree,
4855                                budget,
4856                                &context,
4857                            )
4858                            .await
4859                        }
4860                    },
4861                )
4862                .await;
4863                (e2e, verify_retried, false, None)
4864            };
4865
4866            let expected = records.len();
4867            let answered = records.iter().filter(|r| r.failed.is_none()).count();
4868            let incomplete = answered < expected;
4869            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
4870            let policy = self.state.config.graph.incomplete_review;
4871            let clean = round_is_clean(
4872                blocking,
4873                e2e_ok,
4874                answered,
4875                expected,
4876                round_quota_missing,
4877                policy,
4878            );
4879
4880            let mut round_record = ReviewRound {
4881                round,
4882                head: head.clone(),
4883                verified_head: None,
4884                verified_at: None,
4885                reviews: records,
4886                e2e,
4887                verify_retried,
4888                e2e_deferred,
4889                e2e_defer_reason,
4890                fix: None,
4891                blocking,
4892                answered,
4893                expected,
4894                clean,
4895                progressed: false,
4896                vote_split,
4897                reconsideration,
4898                verdict: None,
4899            };
4900            // The final vote per seat is its revote where reconsideration
4901            // ran and answered, its initial vote otherwise — the same
4902            // fallback `tally` uses for a judge whose private vote failed.
4903            round_record.verdict = ReviewVote::worst(
4904                round_record
4905                    .final_votes()
4906                    .into_iter()
4907                    .map(|(_, _, vote)| vote),
4908            );
4909            // Which commit and when magi actually attempted to check —
4910            // known the moment a command was dispatched against `head`,
4911            // whether or not it finished: a resource-blocked attempt still
4912            // targeted a specific commit at a specific time, and leaving
4913            // that unrecorded is exactly what made `verification_summary`
4914            // report a fresh attempt as "commit unknown ... recorded before
4915            // this was tracked", indistinguishable from a genuinely old,
4916            // untracked record. Only a deferred or unconfigured round never
4917            // ran at all and has nothing to record — see
4918            // `ReviewRound::verified_head`'s own doc.
4919            if !matches!(
4920                round_record.e2e_status(),
4921                E2eStatus::Deferred | E2eStatus::NotConfigured
4922            ) {
4923                round_record.verified_head = Some(head.clone());
4924                round_record.verified_at = Some(Timestamp::now());
4925            }
4926            let this_round_verification = round_record.verification_summary(&head);
4927
4928            if incomplete {
4929                let missing: Vec<String> = round_record
4930                    .reviews
4931                    .iter()
4932                    .filter(|r| r.failed.is_some())
4933                    .map(|r| format!("review-{}", r.reviewer))
4934                    .collect();
4935                self.state.event(
4936                    "review",
4937                    format!(
4938                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
4939                        missing.join(", ")
4940                    ),
4941                );
4942            }
4943
4944            if clean {
4945                self.state.event(
4946                    "review",
4947                    if incomplete && policy == IncompleteReviewPolicy::Warn {
4948                        format!(
4949                            "round {round}: clean (warn policy, incomplete panel) — no \
4950                             blocking findings from the seats that answered, verification green"
4951                        )
4952                    } else if incomplete {
4953                        format!(
4954                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
4955                             quorum) — no blocking findings from the seats that answered, \
4956                             verification green",
4957                            expected - answered
4958                        )
4959                    } else {
4960                        format!("round {round}: clean — no blocking findings, verification green")
4961                    },
4962                );
4963                self.state.reviews.push(round_record);
4964                self.state.status = RunStatus::Gating;
4965                self.state.save()?;
4966                return Ok(());
4967            }
4968
4969            // Nothing was raised and verification passed, but not every seat
4970            // answered and `round_is_clean` still refused to call it clean —
4971            // either a seat is missing for a reason other than its own quota
4972            // (a crash, a timeout, unparsable output — worth another try), or
4973            // every seat that could have answered lost its quota and nobody
4974            // is left to decide on: re-review rather than send the fixer
4975            // after a round with nothing to fix.
4976            if incomplete && blocking == 0 && e2e_ok {
4977                self.state.reviews.push(round_record);
4978                self.state.save()?;
4979                if round == max_rounds {
4980                    self.state.status = RunStatus::Blocked;
4981                    self.state.event(
4982                        "review",
4983                        format!(
4984                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
4985                             refusing to call it clean",
4986                            expected - answered
4987                        ),
4988                    );
4989                    return Ok(());
4990                }
4991                continue;
4992            }
4993
4994            // Nothing for the fixer to act on (`blocking == 0`) and the only
4995            // reason this round is not clean is that magi itself never got
4996            // a command to run — the shared build cache, not the patch (see
4997            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
4998            // the fixer would invite a change to appease contention that has
4999            // nothing to do with the diff, and would leave this attempt
5000            // sitting in the next round's prompt as if it were about an
5001            // earlier, superseded commit rather than what it actually is:
5002            // the same head, still waiting to be checked. Wait for it the
5003            // same way the final round's own contention is already handled,
5004            // whatever round this happens to be.
5005            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5006                self.state.reviews.push(round_record);
5007                return self
5008                    .stop_reviewing(
5009                        "the round's own verification could not run",
5010                        &shell,
5011                        &winner.worktree,
5012                    )
5013                    .await;
5014            }
5015
5016            if round == max_rounds {
5017                self.state.reviews.push(round_record);
5018                return self
5019                    .stop_reviewing(
5020                        &format!(
5021                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5022                        ),
5023                        &shell,
5024                        &winner.worktree,
5025                    )
5026                    .await;
5027            }
5028
5029            // Fix. The winner's own implementer seat continues its conversation:
5030            // the competition is over, so context is pure benefit now.
5031            let (fix_spec, fix_seat_key) = self.fixer_spec(&winner);
5032            let seat = self.seat(&fix_seat_key, &fix_spec.id);
5033            let blocking_findings: Vec<_> = all_findings
5034                .iter()
5035                .filter(|f| f.severity.blocks())
5036                .cloned()
5037                .collect();
5038            let job = SeatJob {
5039                prompt: prompt::fix(
5040                    &self.state.instruction,
5041                    &blocking_findings,
5042                    this_round_verification.as_ref(),
5043                    round,
5044                    max_rounds,
5045                    &language,
5046                ),
5047                spec: fix_spec.clone(),
5048                seat,
5049                cwd: winner.worktree.clone(),
5050                timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5051                allow_write: true,
5052                sessions,
5053                artifacts: artifacts.clone(),
5054                stem: format!("fix-{round}"),
5055                handover: None,
5056            };
5057            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5058            let cache = self.state.config.cache_dir();
5059            let ctx = WaveCtx {
5060                carry_seats: false,
5061                run: &run_id,
5062                node: "fix",
5063                prompts: &prompts,
5064                cache: cache.as_deref(),
5065                round: Some(round),
5066            };
5067            let (seat, out) =
5068                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5069            let agent_id = seat.agent.clone();
5070
5071            let mut fix = FixRecord {
5072                agent: agent_id,
5073                addressed: Vec::new(),
5074                rejected: Vec::new(),
5075                notes: String::new(),
5076                committed: false,
5077                failed: None,
5078                duration_ms: 0,
5079                continuation: None,
5080            };
5081            let mut continuation = ContinuationRecord::not_needed();
5082            let mut final_seat = seat.clone();
5083            match out {
5084                AgentOutcome::Ok(o) => {
5085                    fix.duration_ms = o.duration_ms;
5086                    let parsed = verdict::extract_json::<FixReport>(&o.text);
5087                    // A parsed report standing next to a command this same
5088                    // reply's own CLI never confirmed the exit status of is
5089                    // not a resolved answer — the identical `CommandEvidence`
5090                    // `state.jobs` renders, read here instead of only on
5091                    // display, per the completion judgment and the shown
5092                    // record needing to agree.
5093                    let incomplete_reason = match &parsed {
5094                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5095                            "the reply parsed, but it reported a command whose own CLI never \
5096                             confirmed an exit status"
5097                                .to_owned(),
5098                        ),
5099                        Ok(_) => None,
5100                        Err(e) => Some(e.to_string()),
5101                    };
5102                    match incomplete_reason {
5103                        None => {
5104                            let report = parsed.expect("checked Ok above");
5105                            fix.addressed = report.addressed;
5106                            fix.rejected = report.rejected;
5107                            fix.notes =
5108                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
5109                        }
5110                        Some(reason) => {
5111                            let (resumed_seat, resolved, failure, cont) = self
5112                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5113                                .await;
5114                            fix.duration_ms += cont.cumulative_wait_ms;
5115                            continuation = cont;
5116                            final_seat = resumed_seat;
5117                            match resolved {
5118                                Some(report) => {
5119                                    fix.addressed = report.addressed;
5120                                    fix.rejected = report.rejected;
5121                                    fix.notes = blind::sanitize_prose(
5122                                        &report.notes,
5123                                        &self.state.config.blind,
5124                                    );
5125                                }
5126                                None => fix.failed = failure,
5127                            }
5128                        }
5129                    }
5130                }
5131                // The CLI's raw error JSON is not a fix report to parse.
5132                AgentOutcome::Dropped(o) => {
5133                    fix.duration_ms = o.duration_ms;
5134                    let why = o
5135                        .dropped
5136                        .as_ref()
5137                        .map(|d| d.why.as_str())
5138                        .unwrap_or("the CLI ended the stream without delivering its answer");
5139                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5140                }
5141                AgentOutcome::Quota(o) => {
5142                    self.state.quota.push(QuotaLoss {
5143                        seat: final_seat.key.clone(),
5144                        node: "fix".to_owned(),
5145                        at: Timestamp::now(),
5146                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5147                    });
5148                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5149                }
5150                AgentOutcome::Failed(e) => fix.failed = Some(e),
5151            }
5152            fix.continuation = Some(continuation);
5153            self.state.seats.insert(final_seat.key.clone(), final_seat);
5154            if let Ok(r) = git::rescue_commit(
5155                &winner.worktree,
5156                &format!("magi: review round {round} fixes (uncommitted work)"),
5157            )
5158            .await
5159            {
5160                self.state.note_withheld("fix", &r.withheld);
5161            }
5162            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5163            fix.committed = after != before;
5164            // Judged by what `git` says moved against base, never by the
5165            // fixer's own `addressed`/`rejected` count — see
5166            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5167            // `patch` snapshot above: swallowing this error would default
5168            // `diff_after` to empty, which almost always differs from a
5169            // non-empty `patch` and reads as "progressed" — exactly backwards
5170            // for a `git` failure the stagnation check cannot see through.
5171            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5172            let progressed = diff_after != patch;
5173            let commit_note = if fix.committed {
5174                "committed"
5175            } else {
5176                "NO new commit"
5177            };
5178            let tree_note = if progressed {
5179                "changed vs base"
5180            } else {
5181                "unchanged vs base"
5182            };
5183            self.state.event(
5184                "fix",
5185                match &fix.failed {
5186                    // Distinct on purpose from "0 addressed, 0 rejected": the
5187                    // fixer's own diff still landed (blocking counts do keep
5188                    // falling round over round), only its adoption report did
5189                    // not come back, so this must never read like every
5190                    // finding was reviewed and declined.
5191                    Some(reason) => {
5192                        format!(
5193                            "round {round}: fixer's adoption report was lost ({reason}); \
5194                             {commit_note}, tree {tree_note}"
5195                        )
5196                    }
5197                    None => format!(
5198                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5199                         {tree_note}{}",
5200                        fix.addressed.len(),
5201                        fix.rejected.len(),
5202                        if continuation.outcome == ContinuationOutcome::Resumed {
5203                            format!(
5204                                " (adoption report recovered after {} continuation(s))",
5205                                continuation.attempts
5206                            )
5207                        } else {
5208                            String::new()
5209                        },
5210                    ),
5211                },
5212            );
5213            round_record.fix = Some(fix);
5214            round_record.progressed = progressed;
5215            self.state.reviews.push(round_record);
5216            self.state.save()?;
5217
5218            // The fixer's own report never came back this round, even after
5219            // `continue_fix_report`'s own budget was spent on it — not an
5220            // ordinary "no report" (dropped stream, quota, plain failure),
5221            // which already reads that way and is left to the existing round
5222            // budget. Stopping here, rather than opening another round, is
5223            // what keeps a next reviewer/fixer wave from ever being
5224            // dispatched onto `winner.worktree` while whatever the seat's
5225            // last call may still have running there is unaccounted for: no
5226            // process liveness check exists (and none is being added — see
5227            // AGENTS.md/this task's own scope), so the only way to honour
5228            // "nothing starts before a valid report returns" is to not start
5229            // anything further on this worktree from this run at all.
5230            if matches!(
5231                continuation.outcome,
5232                ContinuationOutcome::Exhausted
5233                    | ContinuationOutcome::QuotaLost
5234                    | ContinuationOutcome::NoSession
5235            ) {
5236                return self
5237                    .stop_reviewing(
5238                        "the fixer's adoption report never came back, even after resuming its \
5239                         own seat; refusing to start another round against the same worktree \
5240                         while that is unresolved",
5241                        &shell,
5242                        &winner.worktree,
5243                    )
5244                    .await;
5245            }
5246
5247            let streak = self
5248                .state
5249                .reviews
5250                .iter()
5251                .rev()
5252                .take_while(|r| !r.progressed)
5253                .count();
5254            if streak >= STAGNANT_LIMIT {
5255                return self
5256                    .stop_reviewing(
5257                        &format!(
5258                            "the tree has not moved against base for {streak} round(s) in a row"
5259                        ),
5260                        &shell,
5261                        &winner.worktree,
5262                    )
5263                    .await;
5264            }
5265        }
5266        Ok(())
5267    }
5268
5269    /// Decide, from the last recorded round's own verification, whether
5270    /// stopping the review loop is a hand-off or a genuine block.
5271    ///
5272    /// Called once the loop has given up trying — the round budget is spent,
5273    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5274    /// findings still open, never while a round is still clean or the
5275    /// incomplete-panel case handled inline above. Gate and e2e are facts
5276    /// about the tree; a lingering review finding is an opinion, and this
5277    /// workload's own `magi stats` puts reviewer precision low enough
5278    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5279    /// must not by itself stand between a green, verified change and the
5280    /// human who decides what to do with it. A red e2e is not an opinion, so
5281    /// that case still blocks, with the failing command and a tail of its
5282    /// output recorded here rather than left in `run.json` for someone to go
5283    /// find.
5284    ///
5285    /// A round that deferred its own e2e (see [`Config::graph`]'s
5286    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5287    /// only because nothing ran, and treating an empty list as a passing one
5288    /// here is exactly the "deferred painted green" bug this function exists
5289    /// to not have. When the last round's own verification never resolved —
5290    /// deferred on purpose, or a real attempt the shared build cache blocked
5291    /// — this makes (or retries) the real run, on the actual worktree this
5292    /// loop is about to stop touching, before deciding anything. A
5293    /// resource-blocked attempt is likewise never read as either green or
5294    /// red: it is evidence about the machine, not the patch (see
5295    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5296    /// blocked cache leaves this call without deciding rather than guessing
5297    /// — the caller retries on a later reentry.
5298    /// Record, once, that the review loop handed off over a blocking finding
5299    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5300    /// `land` asks the owner even with `land_approval` off. Called from every
5301    /// path that concludes `Gating`; a reentry keeps the first record.
5302    fn record_contested_handoff(&mut self) {
5303        if self.state.contested_handoff.is_some() {
5304            return;
5305        }
5306        let Some(contested) = self
5307            .state
5308            .reviews
5309            .last()
5310            .and_then(ReviewRound::contested_handoff)
5311        else {
5312            return;
5313        };
5314        self.state.event(
5315            "review",
5316            format!(
5317                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5318                 wait for the owner's approval",
5319                contested.findings.len(),
5320                contested.rejecters.len()
5321            ),
5322        );
5323        self.state.contested_handoff = Some(contested);
5324    }
5325
5326    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5327        let round_idx = self.state.reviews.len() - 1;
5328        // A deferred round and a resource-blocked one are the same shape
5329        // here: neither has a real result yet, and both get one more
5330        // attempt. Read off `e2e_status` — the single source for this —
5331        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5332        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5333        // still retries instead of being read as a settled result the
5334        // instant it stops being empty.
5335        let needs_catchup_run = matches!(
5336            self.state.reviews[round_idx].e2e_status(),
5337            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5338        );
5339        if needs_catchup_run {
5340            let round = self.state.reviews[round_idx].round;
5341            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5342            let commands = self.state.config.verify.e2e.clone();
5343            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5344            let cache_dir = self.state.config.cache_dir();
5345            let context = format!(
5346                "round {round}: verification unresolved, catching up before the final decision"
5347            );
5348            let (outcomes, verify_retried) = with_cache_lease(
5349                &mut self.state,
5350                cache_dir.as_deref(),
5351                "e2e",
5352                "e2e",
5353                worktree,
5354                &attempted_head,
5355                timeout,
5356                &context,
5357                |state, budget| {
5358                    let shell = shell.to_vec();
5359                    let commands = commands.clone();
5360                    let context = context.clone();
5361                    async move {
5362                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5363                            .await
5364                    }
5365                },
5366            )
5367            .await;
5368            let last = &mut self.state.reviews[round_idx];
5369            last.e2e = outcomes;
5370            last.verify_retried = verify_retried;
5371            // Always the commit and time this attempt actually targeted,
5372            // whether or not it happens to equal the reviewed `head` and
5373            // whether or not a command finished — see
5374            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5375            // attempt is recorded too, so a later reader sees "attempted
5376            // again at T2" rather than silence.
5377            last.verified_head = Some(attempted_head);
5378            last.verified_at = Some(Timestamp::now());
5379            if verify_inconclusive(&last.e2e) {
5380                // Still not a real result: `e2e_deferred` is left exactly
5381                // as it was, so `needs_catchup_run` above reads
5382                // `ResourceBlocked` (via `e2e_status`, which checks
5383                // `resource_blocked` before `e2e_deferred`) and retries
5384                // again on the next reentry, rather than recording
5385                // contention as a red e2e and blocking the run on it.
5386                self.state.save()?;
5387                return Ok(());
5388            }
5389            last.e2e_deferred = false;
5390        }
5391        let last = &self.state.reviews[round_idx];
5392        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5393
5394        match last.e2e_status() {
5395            E2eStatus::Failed => {
5396                let red: Vec<String> = last
5397                    .e2e
5398                    .iter()
5399                    .filter(|o| !o.ok())
5400                    .map(|o| {
5401                        format!(
5402                            "`{}` -> {:?}\n{}",
5403                            o.command,
5404                            o.code,
5405                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5406                        )
5407                    })
5408                    .collect();
5409                self.state
5410                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5411                self.state.status = RunStatus::Blocked;
5412            }
5413            // `needs_catchup_run` above already retried once this call; if
5414            // it is still blocked, this is magi's own admission it could
5415            // not get a command to run, never a verdict on the patch — the
5416            // run is left exactly where a later reentry can retry again.
5417            E2eStatus::ResourceBlocked => {
5418                self.state.event(
5419                    "review",
5420                    format!(
5421                        "{why}; e2e could not run (shared build cache unavailable); not \
5422                         deciding yet"
5423                    ),
5424                );
5425            }
5426            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5427                self.state.event(
5428                    "review",
5429                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5430                );
5431                self.record_contested_handoff();
5432                self.state.status = RunStatus::Gating;
5433            }
5434        }
5435        self.state.save()?;
5436        Ok(())
5437    }
5438
5439    // ----------------------------------------------------------------- gate
5440
5441    async fn gate(&mut self) -> Result<()> {
5442        // Judged by the review record itself, not by `status`: a solo
5443        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5444        // reentry (see `judge`), and trusting it here is exactly how a run
5445        // that exhausted its review budget got gated and merged a second
5446        // time around. `review_conclusion` recomputes the review loop's own
5447        // verdict from the round records themselves — `Gating` for a clean
5448        // round or a hand-off (see `stop_reviewing`), anything else means the
5449        // loop is still going or genuinely blocked.
5450        // A base the winner could not be replayed onto is a decision, not a
5451        // round: there is no landing tree to gate. Read as its own record for
5452        // the same reason the review verdict is.
5453        if self.state.status == RunStatus::Failed
5454            || self
5455                .state
5456                .base_sync
5457                .as_ref()
5458                .is_some_and(|s| s.conflict.is_some())
5459            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5460                != Some(RunStatus::Gating)
5461        {
5462            return Ok(());
5463        }
5464        if self.state.gate_ran {
5465            // `review_loop` derives its conclusion from the clean review
5466            // record on every reentry and therefore puts a completed run back
5467            // in `Gating`. A recorded gate is a stronger, terminal fact:
5468            // retain its original command output (or lack of any, for a repo
5469            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5470            // doc) and restore `Blocked` on a real failure rather than
5471            // pretending the command is still running or running it a second
5472            // time. `gate_ran == false` remains the only shape — unattempted,
5473            // or a resource-blocked retry — that may still need to execute a
5474            // command.
5475            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5476                self.state.status = RunStatus::Blocked;
5477                self.state.save()?;
5478            }
5479            return Ok(());
5480        }
5481        let Some(winner) = self.state.winner().cloned() else {
5482            return Ok(());
5483        };
5484        self.state.status = RunStatus::Gating;
5485        let mut outcomes = self.run_gate(&winner).await?;
5486        loop {
5487            // A resource-blocked outcome means the gate command never actually
5488            // ran - the shared build cache could not be acquired or confirmed
5489            // fresh in time - which is evidence about the machine, not about
5490            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5491            // Recording it as a red gate would mark a run `Blocked` on nothing
5492            // but contention magi has already logged; leaving `self.state.gate`
5493            // empty and `self.state.gate_ran` false instead keeps the shape
5494            // this function already treats as "still needs to run" (see the
5495            // early-return above), so the next call retries the command
5496            // rather than concluding anything.
5497            if verify_inconclusive(&outcomes) {
5498                self.state.save()?;
5499                return Ok(());
5500            }
5501            if outcomes.iter().all(CommandOutcome::ok) {
5502                break;
5503            }
5504            match self.gate_fix_round(&winner, &outcomes).await? {
5505                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5506                GateFix::Stop => break,
5507                GateFix::Defer => {
5508                    self.state.save()?;
5509                    return Ok(());
5510                }
5511            }
5512        }
5513        let passed = outcomes.iter().all(CommandOutcome::ok);
5514        self.state.gate = outcomes;
5515        self.state.gate_ran = true;
5516        if !passed {
5517            self.state.status = RunStatus::Blocked;
5518            let spent = self.state.gate_fixes.len();
5519            self.state.event(
5520                "gate",
5521                if spent == 0 {
5522                    "gate failed; not merging".to_owned()
5523                } else {
5524                    format!("gate failed after {spent} gate-fix round(s); not merging")
5525                },
5526            );
5527        }
5528        self.state.save()?;
5529        Ok(())
5530    }
5531
5532    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
5533    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
5534    /// after review is clean and never on a candidate awaiting judging.
5535    ///
5536    /// Never fails the run: a non-zero exit or timeout is a warning and a
5537    /// recorded outcome, and the gate remains the single arbiter. Nothing
5538    /// configured means nothing happens - no event, no commit. `commit_all`
5539    /// commits any leftover change under the neutral identity and returns
5540    /// `false` when the tree is clean, so no empty commit is ever made.
5541    async fn run_pre_gate(&mut self, winner: &Candidate) {
5542        let commands = self.state.config.verify.pre_gate.clone();
5543        if commands.is_empty() {
5544            return;
5545        }
5546        let shell = self.state.config.shell();
5547        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5548        let (outcomes, _) = run_commands(
5549            &mut self.state,
5550            "pre_gate",
5551            "pre_gate",
5552            0,
5553            &shell,
5554            &commands,
5555            &winner.worktree,
5556            timeout,
5557        )
5558        .await;
5559        for o in &outcomes {
5560            if !o.ok() {
5561                tracing::warn!(
5562                    "pre_gate `{}` failed ({:?}); the gate decides",
5563                    o.command,
5564                    o.code
5565                );
5566            }
5567            self.state.event(
5568                "pre_gate",
5569                format!(
5570                    "`{}` -> {}",
5571                    o.command,
5572                    if o.ok() {
5573                        "pass".to_owned()
5574                    } else {
5575                        format!(
5576                            "FAIL ({:?})\n{}",
5577                            o.code,
5578                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5579                        )
5580                    }
5581                ),
5582            );
5583        }
5584        self.state.pre_gate = outcomes;
5585        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5586            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5587                Ok(head) => {
5588                    self.state
5589                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
5590                    self.state.pre_gate_commit = Some(head);
5591                }
5592                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5593            },
5594            Ok(false) => {}
5595            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5596        }
5597        if let Err(e) = self.state.save() {
5598            tracing::warn!("could not persist the pre_gate record: {e:#}");
5599        }
5600    }
5601
5602    /// Run `verify.gate` once against the winner's current tree, logging one
5603    /// event per command. Empty when nothing is configured.
5604    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5605        self.run_pre_gate(winner).await;
5606        let shell = self.state.config.shell();
5607        let gate_commands = self.state.config.verify.gate.clone();
5608        // Zero commands has nothing to run and nothing that could touch the
5609        // shared build cache, so it never needs a lease: `Config::cache_dir`
5610        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
5611        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
5612        // otherwise queue behind an unrelated run's lease and come back
5613        // resource-blocked - `gate_ran` would stay false on nothing but
5614        // cache contention, for a step that had nothing to check in the
5615        // first place.
5616        let outcomes = if gate_commands.is_empty() {
5617            Vec::new()
5618        } else {
5619            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5620            let cache_dir = self.state.config.cache_dir();
5621            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5622            let (outcomes, _) = with_cache_lease(
5623                &mut self.state,
5624                cache_dir.as_deref(),
5625                "gate",
5626                "gate",
5627                &winner.worktree,
5628                &head,
5629                timeout,
5630                "final gate",
5631                |state, budget| {
5632                    let shell = shell.clone();
5633                    let gate_commands = gate_commands.clone();
5634                    let worktree = winner.worktree.clone();
5635                    async move {
5636                        let (outcomes, timed_out_pids) = run_commands(
5637                            state,
5638                            "gate",
5639                            "gate",
5640                            0,
5641                            &shell,
5642                            &gate_commands,
5643                            &worktree,
5644                            budget,
5645                        )
5646                        .await;
5647                        (outcomes, false, timed_out_pids)
5648                    }
5649                },
5650            )
5651            .await;
5652            outcomes
5653        };
5654        if outcomes.is_empty() {
5655            // Nothing configured to check — distinct from every other
5656            // silence in this run's event log, since an empty `gate` alone
5657            // no longer says whether the gate ran at all (see
5658            // `RunState::gate_ran`'s own doc).
5659            self.state.event(
5660                "gate",
5661                "no gate commands configured; nothing to check, passing",
5662            );
5663        }
5664        for o in &outcomes {
5665            self.state.event(
5666                "gate",
5667                format!(
5668                    "`{}` -> {}",
5669                    o.command,
5670                    if o.ok() {
5671                        "pass".to_owned()
5672                    } else {
5673                        format!(
5674                            "FAIL ({:?})\n{}",
5675                            o.code,
5676                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5677                        )
5678                    }
5679                ),
5680            );
5681        }
5682        Ok(outcomes)
5683    }
5684
5685    /// One bounded fix round for a failing gate.
5686    ///
5687    /// The fixer is told the failure came from the gate itself, not from a
5688    /// reviewer, and is shown the failed commands, their exit codes and a tail
5689    /// of their output - whatever `[verify].gate` holds, nothing here knows
5690    /// what those commands run. Only a normal non-zero exit that printed
5691    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
5692    /// command or a full disk says nothing about the code, and a fixer sent
5693    /// after it can only appease the machine. The round is judged by what git
5694    /// says moved, never by the fixer's own report, and `verify.e2e` runs
5695    /// again before the gate does, so a fix cannot trade a green gate for a
5696    /// red e2e unnoticed.
5697    async fn gate_fix_round(
5698        &mut self,
5699        winner: &Candidate,
5700        outcomes: &[CommandOutcome],
5701    ) -> Result<GateFix> {
5702        let cap = self.state.config.graph.gate_fix_rounds;
5703        let spent = self.state.gate_fixes.len();
5704        if spent >= cap {
5705            if cap > 0 {
5706                self.state.event(
5707                    "gate",
5708                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
5709                );
5710            }
5711            return Ok(GateFix::Stop);
5712        }
5713        if !gate_fixable(outcomes) {
5714            self.state.event(
5715                "gate",
5716                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5717                 command or similar); not spending a fix round on it",
5718            );
5719            return Ok(GateFix::Stop);
5720        }
5721        let min_free = self.state.config.disk.min_free_bytes;
5722        if min_free > 0 {
5723            match crate::disk::free_bytes(&winner.worktree) {
5724                Ok(free) if crate::disk::enough_space(free, min_free) => {}
5725                Ok(free) => {
5726                    self.state.event(
5727                        "gate",
5728                        format!(
5729                            "only {free} bytes free ({min_free} required by `[disk] \
5730                             min_free_bytes`); not spending a fix round on a failure the disk \
5731                             may explain"
5732                        ),
5733                    );
5734                    return Ok(GateFix::Stop);
5735                }
5736                Err(e) => {
5737                    self.state.event(
5738                        "gate",
5739                        format!("free disk space could not be measured ({e:#}); no fix round"),
5740                    );
5741                    return Ok(GateFix::Stop);
5742                }
5743            }
5744        }
5745
5746        let attempt = spent + 1;
5747        let run_id = self.state.id.clone();
5748        let prompts = self.state.config.prompts.clone();
5749        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5750        let base = self.landing_base();
5751        let (fix_spec, fix_seat_key) = self.fixer_spec(winner);
5752        let seat = self.seat(&fix_seat_key, &fix_spec.id);
5753        let job = SeatJob {
5754            prompt: prompt::gate_fix(
5755                &self.state.instruction,
5756                &failed,
5757                attempt,
5758                cap,
5759                &self.state.config.graph.language,
5760            ),
5761            spec: fix_spec,
5762            seat,
5763            cwd: winner.worktree.clone(),
5764            timeout: Duration::from_secs(self.state.config.graph.timeout_fix),
5765            allow_write: true,
5766            sessions: self.state.config.graph.sessions,
5767            artifacts: agent::artifacts_dir(&self.state.dir()),
5768            stem: format!("gate-fix-{attempt}"),
5769            handover: None,
5770        };
5771        self.state.event(
5772            "gate",
5773            format!("gate failed; gate-fix round {attempt} of {cap}"),
5774        );
5775        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5776        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5777        let cache = self.state.config.cache_dir();
5778        let ctx = WaveCtx {
5779            carry_seats: false,
5780            run: &run_id,
5781            node: "gate-fix",
5782            prompts: &prompts,
5783            cache: cache.as_deref(),
5784            round: None,
5785        };
5786        let (seat, out) = run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
5787        let mut record = GateFixRecord {
5788            agent: seat.agent.clone(),
5789            failed,
5790            notes: String::new(),
5791            committed: false,
5792            error: None,
5793        };
5794        match out {
5795            AgentOutcome::Ok(o) => {
5796                // A missing report is not a failed fix: the round is judged
5797                // by the tree below, and the report only carries prose.
5798                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5799                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5800                }
5801            }
5802            AgentOutcome::Dropped(_) => {
5803                record.error = Some("the CLI dropped the stream".to_owned());
5804            }
5805            AgentOutcome::Quota(o) => {
5806                self.state.quota.push(QuotaLoss {
5807                    seat: seat.key.clone(),
5808                    node: "gate-fix".to_owned(),
5809                    at: Timestamp::now(),
5810                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5811                });
5812                record.error = Some("rate limited (quota); fixer could not run".to_owned());
5813            }
5814            AgentOutcome::Failed(e) => record.error = Some(e),
5815        }
5816        self.state.seats.insert(seat.key.clone(), seat);
5817        if let Ok(r) = git::rescue_commit(
5818            &winner.worktree,
5819            &format!("magi: gate fix {attempt} (uncommitted work)"),
5820        )
5821        .await
5822        {
5823            self.state.note_withheld("gate-fix", &r.withheld);
5824        }
5825        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5826        record.committed = after != before;
5827        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5828        let note = record.error.clone();
5829        self.state.gate_fixes.push(record);
5830        self.state.save()?;
5831        if !changed {
5832            self.state.event(
5833                "gate",
5834                match note {
5835                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5836                    None => format!("gate-fix round {attempt}: the tree did not change"),
5837                },
5838            );
5839            return Ok(GateFix::Stop);
5840        }
5841        self.state.event(
5842            "gate",
5843            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5844        );
5845
5846        let commands = self.state.config.verify.e2e.clone();
5847        if !commands.is_empty() {
5848            let shell = self.state.config.shell();
5849            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5850            let cache_dir = self.state.config.cache_dir();
5851            let context = format!("gate-fix round {attempt}");
5852            let (e2e, _) = with_cache_lease(
5853                &mut self.state,
5854                cache_dir.as_deref(),
5855                "e2e",
5856                "e2e",
5857                &winner.worktree,
5858                &after,
5859                timeout,
5860                &context,
5861                |state, budget| {
5862                    let shell = shell.clone();
5863                    let commands = commands.clone();
5864                    let context = context.clone();
5865                    let worktree = winner.worktree.clone();
5866                    async move {
5867                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5868                            .await
5869                    }
5870                },
5871            )
5872            .await;
5873            if verify_inconclusive(&e2e) {
5874                return Ok(GateFix::Defer);
5875            }
5876            if e2e.iter().any(|o| !o.ok()) {
5877                self.state.event(
5878                    "gate",
5879                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
5880                );
5881                return Ok(GateFix::Stop);
5882            }
5883        }
5884        Ok(GateFix::Retry)
5885    }
5886
5887    // ---------------------------------------------------------------- merge
5888
5889    async fn merge(&mut self) -> Result<()> {
5890        // Same reasoning as `gate`: ask the review and gate records directly
5891        // rather than `status`, which a solo-candidate `judge`/`deliberate`
5892        // skip can rewrite on reentry to something that no longer says
5893        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
5894        // so a hand-off (open findings, green verification) reaches merge
5895        // exactly like a genuinely clean round does.
5896        //
5897        // A run resumed mid-`land` never reaches here at all: `execute`
5898        // recognises `RunStatus::Landing` before it even calls `prep`, and
5899        // routes straight to `run_land` instead. That has to happen a level
5900        // up from this function, not with a check in here, because
5901        // `review_loop`'s own status recomputation (see its doc) runs
5902        // *before* `merge` on every reentry and would otherwise overwrite
5903        // the `Landing` marker with `Gating` before this node ever saw it.
5904        if self
5905            .state
5906            .base_sync
5907            .as_ref()
5908            .is_some_and(|s| s.conflict.is_some())
5909            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5910                != Some(RunStatus::Gating)
5911            // `gate_ran == false` is not "passed" - `gate` leaves it false
5912            // both before it has ever run and when its last attempt was
5913            // resource-blocked (see `Runner::gate`'s own doc), and neither is
5914            // permission to merge on nothing but the review record. Only a
5915            // gate that actually ran - zero commands configured and
5916            // vacuously passed, or one or more that all exited 0 - may
5917            // proceed; `RunState::gate_status` is the single place that
5918            // reading is computed.
5919            || !self.state.gate_status().ok()
5920        {
5921            return Ok(());
5922        }
5923        // This node's own record, not `status`: `status == Ready` is not
5924        // unique to the harmless `MergeMode::None` path this line was
5925        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
5926        // run's PR was closed without merging — and on that run `mode` is
5927        // still `Pr`, so a reentry that fell through here would push and
5928        // open a second pull request. `self.state.merge` is set exactly once
5929        // this node (or `land`) has already produced a verdict, under every
5930        // mode, which is what "already done" actually means here.
5931        if self.state.merge.is_some() {
5932            return Ok(());
5933        }
5934        let Some(winner) = self.state.winner().cloned() else {
5935            return Ok(());
5936        };
5937        let repo = self.state.repo.clone();
5938        let base = self.state.base_branch.clone();
5939        let mode = self.state.config.merge.mode;
5940        let style = self.state.config.merge.style;
5941        let facts = if is_review_run(&self.state) {
5942            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
5943            let start = review_base(
5944                &repo,
5945                &self.state.config.merge.remote,
5946                &base,
5947                &self.state.base_commit,
5948                &winner.branch,
5949            )
5950            .await;
5951            branch_facts(&repo, &start, &winner.branch).await
5952        } else {
5953            None
5954        };
5955        // `None` when the base could not be freshly read: then an adopted
5956        // pull request's title is left alone.
5957        let leaked = if is_review_run(&self.state) {
5958            leaked_subjects(&self.state, &winner.branch).await
5959        } else {
5960            Some(Vec::new())
5961        };
5962        let pr = pr_message_with(&self.state, winner.label, facts.as_ref());
5963        let message = pr.commit_message();
5964
5965        let outcome = match mode {
5966            MergeMode::None => MergeOutcome {
5967                mode,
5968                ok: true,
5969                detail: manual_merge_command(style, &repo, &winner.branch, &message),
5970                empty: false,
5971            },
5972            MergeMode::Pr | MergeMode::Local
5973                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
5974            {
5975                MergeOutcome {
5976                    mode,
5977                    ok: false,
5978                    detail: empty_candidate_detail(&self.state, &base),
5979                    empty: true,
5980                }
5981            }
5982            MergeMode::Local => {
5983                let on = git::current_branch(&repo).await?;
5984                if on.as_deref() != Some(base.as_str()) {
5985                    MergeOutcome {
5986                        mode,
5987                        ok: false,
5988                        detail: format!(
5989                            "{} has {} checked out, not the base branch {base}",
5990                            repo.display(),
5991                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
5992                        ),
5993                        empty: false,
5994                    }
5995                } else if !git::is_clean(&repo).await? {
5996                    MergeOutcome {
5997                        mode,
5998                        ok: false,
5999                        detail: format!("{} is dirty; refusing to merge", repo.display()),
6000                        empty: false,
6001                    }
6002                } else {
6003                    let out = match style {
6004                        MergeStyle::Merge => {
6005                            git::merge_no_ff(&repo, &winner.branch, &message).await?
6006                        }
6007                        MergeStyle::Squash => {
6008                            git::merge_squash(&repo, &winner.branch, &message).await?
6009                        }
6010                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6011                    };
6012                    MergeOutcome {
6013                        mode,
6014                        ok: out.ok(),
6015                        detail: if out.ok() { out.stdout } else { out.stderr },
6016                        empty: false,
6017                    }
6018                }
6019            }
6020            MergeMode::Pr => {
6021                let remote = self.state.config.merge.remote.clone();
6022                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6023                if !pushed.ok() {
6024                    MergeOutcome {
6025                        mode,
6026                        ok: false,
6027                        detail: pushed.stderr,
6028                        empty: false,
6029                    }
6030                } else {
6031                    // A retry or resume of a run whose branch already has an
6032                    // open pull request adopts it rather than failing on a
6033                    // duplicate. Only this winner branch into this base:
6034                    // `branch_for` derives the name from the run id, so a
6035                    // different run's pull request never matches.
6036                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6037                    let out = match pr_merge_plan(found) {
6038                        PrPlan::Create => {
6039                            gh_pr_create(
6040                                &winner.worktree,
6041                                &base,
6042                                &winner.branch,
6043                                &pr.title,
6044                                &pr.body,
6045                            )
6046                            .await
6047                        }
6048                        PrPlan::Adopt { url, title } => {
6049                            self.state
6050                                .event("merge", format!("Pr: adopted open pull request {url}"));
6051                            if title != pr.title
6052                                && (!is_review_run(&self.state)
6053                                    || leaked
6054                                        .as_deref()
6055                                        .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6056                                && let Err(e) =
6057                                    land::set_pr_title(&winner.worktree, &url, &pr.title).await
6058                            {
6059                                tracing::warn!("could not refresh title of {url}: {e:#}");
6060                                self.state
6061                                    .event("merge", format!("Pr: title refresh failed: {e:#}"));
6062                            }
6063                            Ok(url)
6064                        }
6065                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6066                    };
6067                    match out {
6068                        Ok(url) => MergeOutcome {
6069                            mode,
6070                            ok: true,
6071                            detail: url,
6072                            empty: false,
6073                        },
6074                        Err(e) => MergeOutcome {
6075                            mode,
6076                            ok: false,
6077                            detail: e.to_string(),
6078                            empty: false,
6079                        },
6080                    }
6081                }
6082            }
6083        };
6084
6085        self.state.status = match (mode, outcome.ok) {
6086            (MergeMode::None, _) => RunStatus::Ready,
6087            (_, true) => RunStatus::Merged,
6088            (_, false) => RunStatus::Blocked,
6089        };
6090        self.state.event(
6091            "merge",
6092            format!(
6093                "{:?}: {}",
6094                mode,
6095                outcome.detail.lines().next().unwrap_or("")
6096            ),
6097        );
6098        self.state.merge = Some(outcome);
6099        self.state.save()?;
6100
6101        // The PR is open and the run would historically stop here, leaving the
6102        // operator to watch checks, feed review comments back to a fixer, and
6103        // merge. That was done by hand six times in one session before this
6104        // existed. Opt-in, because merging is the one irreversible thing magi
6105        // can do to a repository.
6106        if self.state.config.graph.land
6107            && mode == MergeMode::Pr
6108            && self.state.status == RunStatus::Merged
6109        {
6110            self.run_land().await?;
6111        }
6112        // `run_land` may have left `status` at `Landing` - still waiting on
6113        // CI or the owner's approval, not actually settled - so this has to
6114        // read whatever `status` ended up as here, not the `Merged` this
6115        // function set a few lines up.
6116        self.settle_questions();
6117        Ok(())
6118    }
6119
6120    /// Enter `land`.
6121    ///
6122    /// Shared between a fresh run's first pass through [`Runner::merge`] and
6123    /// a resumed run's re-entry. `land::land` itself is what serialises the
6124    /// two git-mutating moments inside the loop — the rebase push and
6125    /// `gh pr merge` — per repository (see its own doc); nothing here needs
6126    /// to hold a lock across the whole call, and doing so would serialise
6127    /// this run's CI wait against a *different* run's land-approval resume
6128    /// in the same repository, which is exactly the "must not wait on
6129    /// another task" property the daemon's slot-freeing exists to give.
6130    async fn run_land(&mut self) -> Result<()> {
6131        let url = self
6132            .state
6133            .merge
6134            .as_ref()
6135            .map(|m| m.detail.clone())
6136            .unwrap_or_default();
6137        let url = url.lines().next().unwrap_or("").trim().to_owned();
6138        if !url.starts_with("http") {
6139            return Ok(());
6140        }
6141        // A land failure is not a lost run: the work is on a branch and the
6142        // pull request is open, which is exactly where a human takes over.
6143        match land::land(&mut self.state, &url).await {
6144            Ok(pr) if self.state.parked => {
6145                // `land` already saved the parked marker; nothing here
6146                // overrides `status` back to a terminal value while an
6147                // approval is still outstanding.
6148                let _ = pr;
6149            }
6150            Ok(pr) => {
6151                self.state.status = match pr.state {
6152                    land::PrLifecycle::Merged => RunStatus::Merged,
6153                    _ => RunStatus::Blocked,
6154                };
6155                // Downstream of a confirmed merge only - see
6156                // `bump::should_release_bump`'s own doc for why this one
6157                // check covers all three of `land`'s success paths.
6158                // Best-effort: the run already landed, so a failure here
6159                // (the decision call, `gh`, `cargo`) is recorded and never
6160                // turns a landed run into a failed one.
6161                if bump::should_release_bump(self.state.status)
6162                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6163                {
6164                    // The event is the run's own record. Not-eligible cases
6165                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
6166                    // `Err` is a bump that was tried and failed:
6167                    // `after_merge` itself raises the operator notice for
6168                    // that, whether or not a release PR exists yet.
6169                    self.state
6170                        .event("bump", format!("release bump skipped: {e:#}"));
6171                }
6172                // Independent of the bump, and best-effort in the same way:
6173                // findings the merge left open become follow-up tasks.
6174                if self.state.status == RunStatus::Merged {
6175                    crate::followup::after_merge(&mut self.state, &pr.url).await;
6176                }
6177                self.state.save()?;
6178            }
6179            Err(e) => {
6180                self.state.status = RunStatus::Blocked;
6181                self.state.event("land", format!("gave up: {e}"));
6182                self.state.save()?;
6183            }
6184        }
6185        Ok(())
6186    }
6187
6188    // -------------------------------------------------------------- helpers
6189
6190    /// Fetch or create a seat, keeping its conversation across nodes.
6191    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6192        if let Some(existing) = self.state.seats.get(key)
6193            && existing.agent == agent
6194        {
6195            return existing.clone();
6196        }
6197        // A seat that changes agent mints its session id from the agent too,
6198        // like a handover: the old agent's uuid is already taken by the CLI.
6199        let fresh = if self.state.seats.contains_key(key) {
6200            handover_seat(key, agent, self.state.next_seat_seed())
6201        } else {
6202            SeatState::new(key, agent, self.state.seed)
6203        };
6204        self.state.seats.insert(key.to_owned(), fresh.clone());
6205        fresh
6206    }
6207
6208    /// The agent now holding seat `key`: `spec`, unless a handover moved the
6209    /// seat to another roster agent, in which case that agent. Nodes that
6210    /// continue a seat's conversation (deliberation, the votes, a reviewer's
6211    /// reconsideration) must keep talking to whoever answered it, not slip
6212    /// back to the agent that failed it.
6213    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6214        match self.state.seats.get(key) {
6215            Some(s) if s.agent != spec.id => {
6216                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6217            }
6218            _ => spec,
6219        }
6220    }
6221
6222    /// A candidate rendered for judging, with the leak policy applied.
6223    fn view(&self, c: &Candidate) -> CandidateView {
6224        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6225            .unwrap_or_default();
6226        let (patch, _) = blind::sanitize_patch(
6227            &format!("candidate {} patch", c.label),
6228            &raw,
6229            &self.state.config.blind,
6230        );
6231        CandidateView {
6232            label: c.label,
6233            branch: c.branch.clone(),
6234            summary: c.summary.clone(),
6235            stat: c.stat.clone(),
6236            patch,
6237        }
6238    }
6239
6240    /// The full candidate set as prompt text, for seats with no live session.
6241    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6242        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6243        prompt::judge(
6244            "(see above)",
6245            &views,
6246            self.roles.judges.len(),
6247            base_short,
6248            "en",
6249        )
6250    }
6251
6252    /// The final-vote prompt with everything a seat that has no session of its
6253    /// own needs: the candidates, the seat's own ranking and reasons, and the
6254    /// anonymised deliberation it took part in (only when there was one, so a
6255    /// handed-over seat never sees more than the seat it replaces did). The
6256    /// `Final vote` heading stays first.
6257    fn vote_prompt_full(
6258        &self,
6259        j: usize,
6260        labels: &[char],
6261        language: &str,
6262        candidates: &[Candidate],
6263        base_short: &str,
6264    ) -> String {
6265        let mut text = format!(
6266            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6267            prompt::final_vote(labels, language),
6268            self.state.instruction,
6269            self.candidate_block(candidates, base_short)
6270        );
6271        if let Some(own) = self
6272            .state
6273            .judgements
6274            .get(j)
6275            .filter(|r| !r.ranking.is_empty())
6276        {
6277            let reasons = own
6278                .reasons
6279                .iter()
6280                .map(|(k, v)| format!("- {k}: {v}"))
6281                .collect::<Vec<_>>()
6282                .join("\n");
6283            text.push_str(&format!(
6284                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6285                own.ranking.iter().collect::<String>(),
6286                if reasons.is_empty() {
6287                    ""
6288                } else {
6289                    ", because:\n"
6290                }
6291            ));
6292        }
6293        if !self.state.deliberation.is_empty() {
6294            text.push_str("\n# What was argued before this vote\n");
6295            for t in self.transcript(&[], j) {
6296                text.push_str(&format!(
6297                    "\n## {}{}\n\n{}\n",
6298                    t.who,
6299                    if t.is_self { " (you)" } else { "" },
6300                    t.body.trim()
6301                ));
6302            }
6303        }
6304        text
6305    }
6306
6307    /// Anonymised transcript for judge `self_idx`.
6308    ///
6309    /// The initial rankings are always the opening statements. Seeding them
6310    /// only when no turn had been taken yet meant every judge after the first
6311    /// argued against a single voice instead of against the actual split — the
6312    /// disagreement is the information, so it is always on the table.
6313    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6314        let mut turns = Vec::new();
6315        for j in &self.state.judgements {
6316            if j.ranking.is_empty() {
6317                continue;
6318            }
6319            let reasons = j
6320                .reasons
6321                .iter()
6322                .map(|(k, v)| format!("- {k}: {v}"))
6323                .collect::<Vec<_>>()
6324                .join("\n");
6325            turns.push(Turn {
6326                who: format!("Judge {} (opening ranking)", j.judge),
6327                is_self: j.judge == self_idx + 1,
6328                body: format!(
6329                    "Ranked {}{}{reasons}",
6330                    j.ranking.iter().collect::<String>(),
6331                    if reasons.is_empty() {
6332                        ""
6333                    } else {
6334                        ", because:\n"
6335                    }
6336                ),
6337            });
6338        }
6339        for t in self
6340            .state
6341            .deliberation
6342            .iter()
6343            .flat_map(|r| r.turns.iter())
6344            .chain(current)
6345        {
6346            turns.push(Turn {
6347                who: format!("Judge {}", t.judge),
6348                is_self: t.judge == self_idx + 1,
6349                body: t.body.clone(),
6350            });
6351        }
6352        turns
6353    }
6354}
6355
6356/// Does this seat still hold the context a follow-up prompt would rely on?
6357fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6358    agent::has_session(spec.kind, seat, sessions)
6359}
6360
6361/// The next entry in `roster` after `start`, never wrapping back to the
6362/// front, whose id is not in `tried` yet.
6363///
6364/// Starts one past `start` rather than at the front of `roster`: `start` is
6365/// the seat's own original position, and a seat whose candidate slot already
6366/// sits on the roster's second entry must fall through to the third next, not
6367/// restart at the first — which is very likely a different candidate's own
6368/// agent already. Never wraps back past `start`, for the same reason: an
6369/// entry earlier in the roster than the seat's own position is almost
6370/// certainly some *other* candidate slot's own agent, and once the tail of
6371/// the roster is exhausted there are no more untried agents for *this* seat
6372/// to fall through to — the caller's fallback chain ends there, exactly as
6373/// "no further untried agents remain in the list for that seat" asks for.
6374///
6375/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
6376/// the same id twice (an operator's `roles.implementers` typo, or a
6377/// `[[agents]]` list reused across roles) must not let
6378/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
6379/// over `roster` either finds an untried id or runs out, so this always
6380/// terminates regardless of duplicates.
6381fn next_untried_in_roster<'a>(
6382    roster: &'a [AgentSpec],
6383    start: usize,
6384    tried: &BTreeSet<String>,
6385) -> Option<&'a AgentSpec> {
6386    roster
6387        .get(start + 1..)?
6388        .iter()
6389        .find(|s| !tried.contains(&s.id))
6390}
6391
6392/// The successor for a seat, shared by all four seat kinds (implement, judge,
6393/// review, advise). `others` holds the ids that *currently* occupy the other
6394/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
6395/// sees them), so roster entries beyond the seat count act as spares: a failed
6396/// seat goes to an agent no other seat holds whenever the roster permits.
6397///
6398/// `carried` is `Some` only for the review loop's carried failure history.
6399/// Order: (1) forward from `start`, never wrapping, untried, not a carried
6400/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
6401/// whole roster: untried and not another seat's occupant; (3) the plain walk
6402/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
6403/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
6404/// a better outcome than an empty seat, and it keeps the answer to "is there
6405/// a successor at all" exactly what it was before occupants were considered,
6406/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
6407/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
6408/// carried failure is a cheaper bet than doubling an agent on the panel, and
6409/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
6410/// the same round are handled one at a time, so a seat later in the batch
6411/// sees an earlier one's new occupant but not yet its own freed agent.
6412fn pick_successor<'a>(
6413    roster: &'a [AgentSpec],
6414    start: usize,
6415    tried: &BTreeSet<String>,
6416    carried: Option<&BTreeSet<String>>,
6417    others: &BTreeSet<String>,
6418) -> Option<&'a AgentSpec> {
6419    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6420    roster
6421        .get(start + 1..)
6422        .and_then(|tail| {
6423            tail.iter()
6424                .filter(free)
6425                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6426        })
6427        .or_else(|| {
6428            carried
6429                .is_some()
6430                .then(|| roster.iter().find(free))
6431                .flatten()
6432        })
6433        .or_else(|| match carried {
6434            Some(c) => next_for_seat(roster, start, tried, c),
6435            None => next_untried_in_roster(roster, start, tried),
6436        })
6437}
6438
6439/// The next agent for a seat that carries its failure history across rounds
6440/// (the review loop). `round_tried` is this round's own bound and starts
6441/// empty every round; `carried_failed` only decides priority.
6442///
6443/// First: an id walking forward from `start`, never wrapping, that is neither
6444/// tried this round nor failed in an earlier one. Only when that is exhausted
6445/// does it rescue: the first roster id (in roster order, so this one *does*
6446/// look before `start`) not yet tried this round, which is by then a carried
6447/// failure. Each id is rescued at most once per round, so it cannot loop.
6448fn next_for_seat<'a>(
6449    roster: &'a [AgentSpec],
6450    start: usize,
6451    round_tried: &BTreeSet<String>,
6452    carried_failed: &BTreeSet<String>,
6453) -> Option<&'a AgentSpec> {
6454    roster
6455        .get(start + 1..)?
6456        .iter()
6457        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6458        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6459}
6460
6461/// Where a reviewer seat starts a round: the agent that last answered it when
6462/// it is still on the roster and not marked failed, else the spec's own agent
6463/// unless it failed, else the next roster agent that has not failed, else the
6464/// spec's own agent again (the whole roster failed). Ids no longer on the
6465/// roster are ignored. An empty roster has no handover, so the spec stands.
6466fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6467    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6468        return spec;
6469    };
6470    let ok = |id: &str| !h.failed.contains(id);
6471    if let Some(last) = h.last_ok.as_deref()
6472        && ok(last)
6473        && let Some(s) = roster.iter().find(|s| s.id == last)
6474    {
6475        return s.clone();
6476    }
6477    if ok(&spec.id) {
6478        return spec;
6479    }
6480    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6481    roster
6482        .get(from + 1..)
6483        .into_iter()
6484        .flatten()
6485        .chain(roster.iter())
6486        .find(|s| ok(&s.id))
6487        .cloned()
6488        .unwrap_or(spec)
6489}
6490
6491/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
6492/// seed so a CLI that mints its session id up front (`--session-id`) never
6493/// reuses the uuid the previous agent already opened under the same seat key.
6494fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
6495    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
6496}
6497
6498/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
6499/// so the classifier below and the code that builds the message cannot drift.
6500const TIMED_OUT: &str = "timed out";
6501
6502impl FailClass {
6503    /// `None` for an answer; otherwise how the turn failed.
6504    fn of(out: &AgentOutcome) -> Option<Self> {
6505        match out {
6506            AgentOutcome::Ok(_) => None,
6507            AgentOutcome::Quota(_) => Some(Self::Quota),
6508            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
6509            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
6510            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
6511        }
6512    }
6513
6514    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
6515    fn stem_word(&self) -> &'static str {
6516        match self {
6517            Self::Quota => "quota",
6518            _ => "handover",
6519        }
6520    }
6521}
6522
6523/// The message's first line with its variable parts removed — digit runs and
6524/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
6525/// as one kind of failure.
6526fn failure_signature(msg: &str) -> String {
6527    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
6528    let mut out = Vec::new();
6529    for word in line.split_whitespace() {
6530        if word.contains('/') || word.contains('\\') {
6531            out.push("<path>".to_owned());
6532            continue;
6533        }
6534        let mut w = String::new();
6535        let mut in_digits = false;
6536        for c in word.chars() {
6537            if c.is_ascii_digit() {
6538                if !in_digits {
6539                    w.push('#');
6540                }
6541                in_digits = true;
6542            } else {
6543                in_digits = false;
6544                w.push(c);
6545            }
6546        }
6547        out.push(w);
6548    }
6549    out.join(" ").chars().take(120).collect()
6550}
6551
6552/// Whether a seat that just failed with `cur` may go to the next roster agent.
6553/// A quota or a timeout always may. Any other failure may not when the agent
6554/// before it failed the same way: an error the prompt causes would otherwise
6555/// walk the whole roster. `prev` is the class of the immediately preceding
6556/// agent's failure, so a quota or timeout in between breaks the run of
6557/// identical failures by itself.
6558fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
6559    match cur {
6560        FailClass::Quota | FailClass::Timeout => true,
6561        FailClass::Other(_) => prev != Some(cur),
6562    }
6563}
6564
6565/// A short human reason for a failed outcome, for the handover record.
6566fn fail_reason(out: &AgentOutcome) -> String {
6567    match out {
6568        AgentOutcome::Ok(_) => String::new(),
6569        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
6570        AgentOutcome::Dropped(o) => format!(
6571            "the CLI dropped the stream ({})",
6572            o.dropped
6573                .as_ref()
6574                .map(|d| d.why.as_str())
6575                .unwrap_or("it ended without delivering its answer")
6576        ),
6577        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
6578    }
6579}
6580
6581/// Note one handover in the run: the structured record and, in the timeline,
6582/// the sentence a person reads. A quota keeps the wording it always had.
6583fn record_handover(
6584    state: &mut RunState,
6585    node: &str,
6586    seat: &str,
6587    from: &str,
6588    to: &str,
6589    class: &FailClass,
6590    reason: &str,
6591) {
6592    let message = if *class == FailClass::Quota {
6593        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
6594    } else {
6595        format!("{seat}: handed over {from} -> {to} ({reason})")
6596    };
6597    state.event(node, message);
6598    state.handovers.push(Handover {
6599        at: Timestamp::now(),
6600        node: node.to_owned(),
6601        seat: seat.to_owned(),
6602        from: from.to_owned(),
6603        to: to.to_owned(),
6604        reason: reason.to_owned(),
6605    });
6606}
6607
6608/// Did this reply report running a command whose own CLI never confirmed an
6609/// exit status?
6610///
6611/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
6612/// command *finished* (see that type's own doc), so this can only be `true`
6613/// for a command whose completion event carried no readable exit code — not
6614/// for one that simply is not mentioned at all. That is the one signal this
6615/// crate can read, from the same record `state.jobs` renders, about a reply
6616/// standing next to work its own CLI cannot vouch for finishing; it is
6617/// deliberately not a check on the exit code's *value* (a fixer legitimately
6618/// runs a command that fails mid-iteration before it succeeds) and not a
6619/// guess at a command still running in the background (which emits no event
6620/// at all, and so leaves no evidence here to find).
6621fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
6622    commands.iter().any(|c| c.exit_code.is_none())
6623}
6624
6625/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
6626/// trusted as a verified no-op — the adoption guard's own text-level half.
6627///
6628/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
6629/// timed out): a marker only earns the benefit of the doubt from a turn the
6630/// CLI itself vouches for finishing properly, the same house style
6631/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
6632/// *fix* report to for `commands`. A candidate that timed out, exited
6633/// non-zero, or left a command unconfirmed is read as the ordinary loss it
6634/// is, whatever prose it wrote — this returns `None` before it ever looks at
6635/// `text`. The remaining guards (the tree really is empty, the evidence is
6636/// non-empty) are the caller's: this only reads what the reply *claimed*.
6637fn verified_noop_claim(
6638    usable: bool,
6639    commands: &[agent::CommandEvidence],
6640    text: &str,
6641) -> Option<String> {
6642    (usable && !has_unconfirmed_command(commands))
6643        .then(|| verdict::verified_noop(text))
6644        .flatten()
6645}
6646
6647fn short(commit: &str) -> String {
6648    commit.chars().take(7).collect()
6649}
6650
6651fn make_executable(path: &Path) -> Result<()> {
6652    #[cfg(unix)]
6653    {
6654        use std::os::unix::fs::PermissionsExt as _;
6655        let mut perms = std::fs::metadata(path)?.permissions();
6656        perms.set_mode(0o755);
6657        std::fs::set_permissions(path, perms)?;
6658    }
6659    #[cfg(not(unix))]
6660    {
6661        let _ = path;
6662    }
6663    Ok(())
6664}
6665
6666/// What every seat in one batch shares: where the answers are attributed, the
6667/// prompt overlay they inherit, and the build cache they are told to use.
6668///
6669/// A struct rather than four more parameters: `wave` also needs the run's
6670/// state (to record who is answering right now) and the attempt number, and
6671/// eight positional arguments is both unreadable and a clippy error.
6672struct WaveCtx<'a> {
6673    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
6674    /// paid for it.
6675    run: &'a str,
6676    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
6677    node: &'a str,
6678    prompts: &'a Prompts,
6679    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
6680    cache: Option<&'a Path>,
6681    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
6682    /// `JobRecord::round`. `None` for every other node.
6683    round: Option<usize>,
6684    /// Carry each seat's failed-agent history across waves (the review loop
6685    /// only): start-of-round priority and handover choice read
6686    /// [`RunState::seat_history`], and every answer or failure writes it.
6687    carry_seats: bool,
6688}
6689
6690/// Run one job, honouring the parallelism budget.
6691async fn run_one(
6692    job: SeatJob,
6693    sem: Arc<Semaphore>,
6694    ctx: &WaveCtx<'_>,
6695    state: &mut RunState,
6696    attempt: usize,
6697) -> (SeatState, AgentOutcome) {
6698    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
6699        .await
6700        .pop()
6701        .expect("one job in, one result out");
6702    (seat, out)
6703}
6704
6705/// Run every job concurrently, capped by the semaphore, preserving order.
6706///
6707/// Every seat in the batch is recorded into [`RunState::active`] before the
6708/// wave starts and cleared as each answer lands, so the run's own record says
6709/// who is still being waited on rather than only who finished.
6710async fn wave(
6711    jobs: Vec<SeatJob>,
6712    sem: Arc<Semaphore>,
6713    ctx: &WaveCtx<'_>,
6714    state: &mut RunState,
6715    attempt: usize,
6716) -> Vec<(usize, SeatState, AgentOutcome)> {
6717    let WaveCtx {
6718        run,
6719        node,
6720        prompts,
6721        cache,
6722        round,
6723        carry_seats: _,
6724    } = *ctx;
6725    for job in &jobs {
6726        state.seat_started(node, &job.seat.key, job.timeout, attempt);
6727    }
6728    if let Err(e) = state.save() {
6729        // A failed persist of "who is answering right now" must not abort the
6730        // wave: the seats are already being asked, and the alternative is
6731        // losing the answers to save a status line nobody may even be
6732        // watching.
6733        tracing::warn!("could not persist in-progress seats: {e:#}");
6734    }
6735    // Hold the shared build cache's lease for the whole batch, not per job:
6736    // several candidates (an implement wave) or a fixer legitimately share
6737    // one cache concurrently within this run, and that stays untouched — a
6738    // single lease taken once for the whole wave and released once it is
6739    // done is what stops a *different* borrower (another run's own wave, its
6740    // e2e/gate, a human's `magi review`) from interleaving a build into the
6741    // same directory while this one is in flight. Best-effort, not
6742    // all-or-nothing: a wave that cannot get the lease within its own
6743    // longest job's budget still runs — an hour of paid implementer calls is
6744    // not thrown away over cache contention — but every write-allowed seat
6745    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
6746    // below), the same fallback a read-only seat always gets, rather than
6747    // building into a directory this run was never granted. The identity
6748    // record is still invalidated below either way, so the next tracked
6749    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
6750    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
6751    let wait_started = Instant::now();
6752    let cache_guard = if let Some(cache_dir) = cache {
6753        if jobs_had_a_writer {
6754            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
6755            let budget = jobs
6756                .iter()
6757                .map(|j| j.timeout)
6758                .max()
6759                .unwrap_or(Duration::from_secs(60));
6760            acquire_cache_lease(state, cache_dir, &owner, budget, node)
6761                .await
6762                .ok()
6763        } else {
6764            None
6765        }
6766    } else {
6767        None
6768    };
6769    // Carved out of each job's own budget, not added on top of it: a seat
6770    // that waited behind the lease must not also get its full timeout
6771    // afterward, or a run contended on the cache could double the time it
6772    // spends per wave. `saturating_sub` floors at zero rather than
6773    // wrapping - a job whose whole budget was spent waiting starts with
6774    // none left, which is the honest number, not a free minimum.
6775    let waited_for_lease = wait_started.elapsed();
6776    let mut set = tokio::task::JoinSet::new();
6777    let overlay = prompts.overlay(node);
6778    for (i, mut job) in jobs.into_iter().enumerate() {
6779        job.timeout = job.timeout.saturating_sub(waited_for_lease);
6780        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
6781        if cache.is_some() {
6782            job.prompt.push('\n');
6783            job.prompt
6784                .push_str(&prompt::build_cache_note(node, job.allow_write));
6785        }
6786        let sem = Arc::clone(&sem);
6787        let run = run.to_owned();
6788        let node = node.to_owned();
6789        // Only implementers were told about the task's attachments, so only
6790        // their seats get the directory widened for reading.
6791        let attachments = if node == "implement" {
6792            state.attachments.clone()
6793        } else {
6794            Vec::new()
6795        };
6796        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
6797        // `prompt::build_cache_note`'s doc for why setting it anyway is
6798        // exactly how a sandboxed reviewer's write refusal got reported as a
6799        // defect in the patch, not a property of its own seat. And a
6800        // write-allowed one is handed it only when the lease above was
6801        // actually acquired: a wave that could not get it (`cache_guard` is
6802        // `None`, see its own comment) must not send seats to build into a
6803        // directory this run does not hold - that is the exact concurrent,
6804        // unmanaged-write race this module exists to prevent, not something
6805        // "proceeding anyway" is allowed to reintroduce.
6806        let cache = cache
6807            .filter(|_| job.allow_write && cache_guard.is_some())
6808            .map(Path::to_path_buf);
6809        set.spawn(async move {
6810            let _permit = sem.acquire().await;
6811            let mut seat = job.seat;
6812            let out = agent::invoke(
6813                &job.spec,
6814                &mut seat,
6815                &Invocation {
6816                    cwd: &job.cwd,
6817                    prompt: &job.prompt,
6818                    timeout: job.timeout,
6819                    allow_write: job.allow_write,
6820                    sessions: job.sessions,
6821                    artifacts: &job.artifacts,
6822                    stem: &job.stem,
6823                    run: &run,
6824                    node: &node,
6825                    cache_dir: cache.as_deref(),
6826                    attachments: &attachments,
6827                    writable: &[],
6828                },
6829            )
6830            .await;
6831            let out = match out {
6832                Ok(o) if o.usable() => AgentOutcome::Ok(o),
6833                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
6834                // Billed work the CLI failed to hand over is not an ordinary
6835                // failure, but its text is the CLI's raw error JSON, not an
6836                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
6837                // read it as one by forgetting to check. `usable()` is always
6838                // false here (dropped implies an empty response), so this has
6839                // to be checked before the catch-all `Failed` below or the
6840                // one shape this exists for is lost with the rest.
6841                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
6842                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
6843                Ok(o) => AgentOutcome::Failed(format!(
6844                    "exited with {:?} and no usable output",
6845                    o.exit_code
6846                )),
6847                Err(e) => AgentOutcome::Failed(e.to_string()),
6848            };
6849            (i, seat, out)
6850        });
6851    }
6852    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
6853    while let Some(joined) = set.join_next().await {
6854        let (i, seat, out) = match joined {
6855            Ok(v) => v,
6856            // No seat to clear: a panicked task never reported which one it
6857            // was. The defensive sweep below this loop is what stops that
6858            // seat's `active` entry from surviving forever.
6859            Err(e) => {
6860                tracing::error!("agent task panicked: {e}");
6861                continue;
6862            }
6863        };
6864        state.seat_finished(&seat.key);
6865        record_jobs(state, node, round, &seat.key, &out);
6866        if let Err(e) = state.save() {
6867            tracing::warn!("could not persist a seat's completion: {e:#}");
6868        }
6869        if collected.len() <= i {
6870            collected.resize_with(i + 1, || None);
6871        }
6872        collected[i] = Some((i, seat, out));
6873    }
6874    // Belt-and-braces for the panic branch above: every seat this exact batch
6875    // started shares this `(node, attempt)` pair, and every seat that finished
6876    // normally already cleared itself, so anything left tagged with it here
6877    // can only be a panicked task's leftover. Cleared unconditionally rather
6878    // than left to read as still answering forever.
6879    if state
6880        .active
6881        .values()
6882        .any(|a| a.node == node && a.attempt == attempt)
6883    {
6884        state
6885            .active
6886            .retain(|_, a| !(a.node == node && a.attempt == attempt));
6887        if let Err(e) = state.save() {
6888            tracing::warn!("could not persist the end of a wave: {e:#}");
6889        }
6890    }
6891    // Whether or not the lease above was actually held, several worktrees
6892    // may just have built into the cache with nothing here able to name one
6893    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
6894    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
6895    // clean it might not have strictly needed; trusting a stale match would
6896    // cost it a wrong answer.
6897    if let Some(cache_dir) = cache
6898        && jobs_had_a_writer
6899    {
6900        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
6901    }
6902    if let Some(guard) = cache_guard {
6903        guard.release();
6904    }
6905    collected.into_iter().flatten().collect()
6906}
6907
6908/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
6909/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
6910/// this is data collection, not the fix-specific completion contract in
6911/// [`Runner::continue_fix_report`], and applies regardless of which node
6912/// asked.
6913///
6914/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
6915/// evidence from; `Failed` does not, and correctly contributes nothing — a
6916/// timeout or crash is not itself evidence about a command the seat may have
6917/// started.
6918fn record_jobs(
6919    state: &mut RunState,
6920    node: &str,
6921    round: Option<usize>,
6922    seat: &str,
6923    out: &AgentOutcome,
6924) {
6925    let commands: &[agent::CommandEvidence] = match out {
6926        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
6927        AgentOutcome::Failed(_) => &[],
6928    };
6929    let checked_at = Timestamp::now();
6930    for c in commands {
6931        state.jobs.push(JobRecord {
6932            node: node.to_owned(),
6933            round,
6934            seat: seat.to_owned(),
6935            id: c.id.clone(),
6936            description: c.description.clone(),
6937            checked_at,
6938            status: match c.exit_code {
6939                Some(0) => JobStatus::Completed,
6940                Some(_) => JobStatus::Failed,
6941                None => JobStatus::Unknown,
6942            },
6943            exit_code: c.exit_code,
6944            result_summary: c.result_summary.clone(),
6945            source: c.source.clone(),
6946        });
6947    }
6948}
6949
6950/// Is a review round clean, given how many reviewer seats answered against
6951/// how many the round expected?
6952///
6953/// A seat that never answered (timeout, crash, unparsable output) is not a
6954/// seat that read the patch and found nothing — treating it as such is
6955/// exactly the bug this function exists to close. Under the default `block`
6956/// policy a missing seat can never be clean; `warn` still requires the seats
6957/// that *did* answer to have found nothing blocking and verification to be
6958/// green.
6959///
6960/// `quota_missing` narrows that `block` default for exactly one cause of
6961/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
6962/// a session limit lifts by the very next round buys nothing — the seat is
6963/// asked again with the same quota — so once every missing seat is accounted
6964/// for by a quota loss (and at least one seat *did* answer, so a decision has
6965/// something to rest on) the round is decided on the panel that could answer,
6966/// same as `warn` would. A panel that lost every seat to quota is not
6967/// decided here: `answered == 0` falls through to the existing `block`
6968/// fallback so a fully collapsed panel still waits rather than landing on no
6969/// review at all.
6970fn round_is_clean(
6971    blocking: usize,
6972    e2e_ok: bool,
6973    answered: usize,
6974    expected: usize,
6975    quota_missing: usize,
6976    policy: IncompleteReviewPolicy,
6977) -> bool {
6978    if blocking != 0 || !e2e_ok {
6979        return false;
6980    }
6981    if answered == expected || policy == IncompleteReviewPolicy::Warn {
6982        return true;
6983    }
6984    answered > 0 && expected - answered <= quota_missing
6985}
6986
6987/// The review loop's own conclusion, derived entirely from its persisted
6988/// round records and the round budget that produced them — never from
6989/// `status`, so a reentry (or `gate`/`merge` reading it independently)
6990/// recomputes the identical answer regardless of what an earlier node in the
6991/// same walk, or a previous walk, did to `status`.
6992///
6993/// `None` while more rounds remain to try, including when review never ran
6994/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
6995/// gone clean, or the budget is spent, or the tree has stopped moving (see
6996/// [`STAGNANT_LIMIT`]), the answer is one of two things:
6997///
6998/// - An incomplete panel that raised nothing is missing input, not a
6999///   verified tree — never a hand-off candidate, whatever verification said
7000///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
7001/// - Otherwise, green e2e on the last round hands off (see
7002///   [`Runner::stop_reviewing`]); red e2e blocks.
7003///
7004/// A last round whose own verification is still `ResourceBlocked` — magi
7005/// itself never got a command to run, not evidence the patch is broken —
7006/// is neither: this returns `None` for it too, the same as "more rounds
7007/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
7008/// handling of that shape) instead of this cheap recomputation guessing a
7009/// verdict a real attempt never produced.
7010fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7011    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7012        return Some(RunStatus::Gating);
7013    }
7014    let last = reviews.last()?;
7015    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7016    if reviews.len() < max_rounds && !stagnant {
7017        return None;
7018    }
7019    if last.incomplete() && last.blocking == 0 {
7020        return Some(RunStatus::Blocked);
7021    }
7022    if last.e2e_status() == E2eStatus::ResourceBlocked {
7023        return None;
7024    }
7025    Some(if last.e2e.iter().all(CommandOutcome::ok) {
7026        RunStatus::Gating
7027    } else {
7028        RunStatus::Blocked
7029    })
7030}
7031
7032/// How long a re-ask may take, given the budget the first attempt had.
7033///
7034/// A `nudged` retry is a request to restate an answer the seat has already
7035/// worked out: it carries no new work, so it does not deserve the original
7036/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
7037/// 133 seconds while a third sat for over ten minutes on a resumed session
7038/// holding 410 KB of prior output - and because the retry had inherited the
7039/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
7040/// judging round whose other seats were long finished.
7041///
7042/// A quarter of the budget, with a floor so that a deliberately short timeout
7043/// does not collapse to nothing. A retry that re-sends the whole prompt
7044/// (because the seat kept no context) is the original job again, and keeps the
7045/// original budget.
7046fn retry_budget(full: Duration, nudged: bool) -> Duration {
7047    if nudged {
7048        (full / 4).max(Duration::from_secs(120)).min(full)
7049    } else {
7050        full
7051    }
7052}
7053
7054/// Run a wave and parse each reply, re-asking the seats whose reply was
7055/// unusable.
7056///
7057/// The re-ask is a nudge rather than the whole prompt again when the seat still
7058/// holds its conversation, which is the difference between a cheap retry and
7059/// paying for the entire candidate set twice.
7060///
7061/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
7062/// successor in `roster` is handed to it instead of being re-asked: the
7063/// handover is the retry. A seat with no successor left (a single-agent
7064/// roster, the roster's tail) is nudged as before, up to `retries` times. So
7065/// the asks to one seat in one node number at most
7066/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
7067/// is asked once (a dropped stream is nudged first), and only the last agent
7068/// of the chain gets the `retries` same-agent nudges. Each roster agent is
7069/// tried at most once per seat, walking forward from the seat's own position and never wrapping
7070/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
7071/// other failure stops the chain when the previous agent failed the same way
7072/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
7073/// [`has_context`] is false and the job's own full prompt and full budget are
7074/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
7075/// intermediate ones are not losses) and is returned as a failure like any
7076/// other absent seat — the caller decides whether the panel still has a
7077/// quorum. An empty `roster` disables handover: failures are nudged as they
7078/// always were, and a quota is simply lost. A reply that fails to parse or
7079/// validate is the prompt's doing and is only ever nudged, never handed over.
7080///
7081/// The returned [`SeatState`] names the agent that answered (or tried last).
7082#[allow(clippy::too_many_arguments)]
7083async fn ask_json_wave<T>(
7084    jobs: Vec<SeatJob>,
7085    sem: Arc<Semaphore>,
7086    retries: usize,
7087    roster: &[AgentSpec],
7088    ctx: &WaveCtx<'_>,
7089    losses: &mut Vec<QuotaLoss>,
7090    state: &mut RunState,
7091    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7092) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7093where
7094    T: serde::de::DeserializeOwned + Send + 'static,
7095{
7096    ask_wave_with(
7097        jobs,
7098        sem,
7099        retries,
7100        roster,
7101        ctx,
7102        losses,
7103        state,
7104        &|text: &str| {
7105            let v = verdict::extract_json::<T>(text)?;
7106            validate(&v)?;
7107            Ok(v)
7108        },
7109    )
7110    .await
7111}
7112
7113/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
7114/// a node whose answer is prose (deliberation) shares the same handover,
7115/// failure classification, quota bookkeeping and bounds instead of a copy.
7116///
7117/// A seat handed to another roster agent is sent the job's `handover` prompt
7118/// (when it has one) rather than `prompt`: the new agent has no session, so a
7119/// resume-style prompt would be incomplete. That holds for the handover ask
7120/// and for every nudge to that agent whose `has_context` is false.
7121#[allow(clippy::too_many_arguments)]
7122async fn ask_wave_with<T>(
7123    jobs: Vec<SeatJob>,
7124    sem: Arc<Semaphore>,
7125    retries: usize,
7126    roster: &[AgentSpec],
7127    ctx: &WaveCtx<'_>,
7128    losses: &mut Vec<QuotaLoss>,
7129    state: &mut RunState,
7130    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7131) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7132where
7133    T: Send + 'static,
7134{
7135    let n = jobs.len();
7136    let originals: Vec<SeatJob> = jobs;
7137    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7138    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7139    // Nudges each seat's *current* agent has taken — 0 for a first-ask
7140    // answer, N once it has gone through N nudges. Read back once this
7141    // returns, so a caller building a history record (`ReviewRecord`) can
7142    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
7143    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
7144    // field's own doc.
7145    let mut nudges: Vec<usize> = vec![0; n];
7146    // The agent now occupying each seat, the ids it has already been through,
7147    // where in the roster the walk began, the class of the last failure, and
7148    // the stem word of a handover not yet asked (full prompt, full budget).
7149    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7150    let mut tried: Vec<BTreeSet<String>> = specs
7151        .iter()
7152        .map(|s| BTreeSet::from([s.id.clone()]))
7153        .collect();
7154    let starts: Vec<usize> = specs
7155        .iter()
7156        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7157        .collect();
7158    let carry = ctx.carry_seats && !roster.is_empty();
7159    // Carried across rounds: ids that failed the seat earlier, and how the
7160    // last failure went (so a repeat of it is not handed over again).
7161    let carried: Vec<BTreeSet<String>> = originals
7162        .iter()
7163        .map(|j| {
7164            state
7165                .seat_history
7166                .get(&j.seat.key)
7167                .filter(|_| carry)
7168                .map(|h| h.failed.clone())
7169                .unwrap_or_default()
7170        })
7171        .collect();
7172    let mut prev: Vec<Option<FailClass>> = originals
7173        .iter()
7174        .map(|j| {
7175            state
7176                .seat_history
7177                .get(&j.seat.key)
7178                .filter(|_| carry)
7179                .and_then(|h| h.last_fail.clone())
7180        })
7181        .collect();
7182    let next_agent =
7183        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7184            let others: BTreeSet<String> = specs
7185                .iter()
7186                .enumerate()
7187                .filter(|(j, _)| *j != i)
7188                .map(|(_, s)| s.id.clone())
7189                .collect();
7190            pick_successor(
7191                roster,
7192                starts[i],
7193                tried,
7194                carry.then(|| &carried[i]),
7195                &others,
7196            )
7197            .cloned()
7198        };
7199    let mut fresh: Vec<Option<String>> = vec![None; n];
7200    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7201    let mut pending: Vec<usize> = (0..n).collect();
7202
7203    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
7204    // asks: an agent with a successor is asked once and handed over, and only
7205    // a seat with no successor spends `retries` nudges on the same agent. This
7206    // only guarantees the loop's own termination whatever those say.
7207    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7208    for round in 0..max_rounds {
7209        if pending.is_empty() {
7210            break;
7211        }
7212        let mut batch = Vec::with_capacity(pending.len());
7213        let mut renudged: Vec<&str> = Vec::new();
7214        for &i in &pending {
7215            let src = &originals[i];
7216            // A seat now held by another agent than the job named has no
7217            // session of its own: it gets the full-context prompt whenever
7218            // it is asked in full (the handover ask, a nudge it cannot
7219            // resume).
7220            let full: &str = match &src.handover {
7221                Some(h) if specs[i].id != src.spec.id => h,
7222                _ => &src.prompt,
7223            };
7224            // The prompt and the budget are one decision: a nudge restates
7225            // finished work, a re-sent prompt redoes it.
7226            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7227                (
7228                    full.to_owned(),
7229                    src.timeout,
7230                    format!("{}-{word}-{}", src.stem, specs[i].id),
7231                )
7232            } else if nudges[i] == 0 {
7233                (full.to_owned(), src.timeout, src.stem.clone())
7234            } else {
7235                renudged.push(src.seat.key.as_str());
7236                let why = done[i]
7237                    .as_ref()
7238                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
7239                    .unwrap_or_else(|| "no parsable answer".to_owned());
7240                let nudge = prompt::nudge(&why);
7241                let nudged = has_context(&specs[i], &seats[i], src.sessions);
7242                let prompt = if nudged {
7243                    nudge
7244                } else {
7245                    format!("{full}\n\n---\n\n{nudge}")
7246                };
7247                (
7248                    prompt,
7249                    retry_budget(src.timeout, nudged),
7250                    format!("{}-retry{}", src.stem, nudges[i]),
7251                )
7252            };
7253            batch.push(SeatJob {
7254                spec: specs[i].clone(),
7255                seat: seats[i].clone(),
7256                cwd: src.cwd.clone(),
7257                prompt,
7258                timeout,
7259                allow_write: src.allow_write,
7260                sessions: src.sessions,
7261                artifacts: src.artifacts.clone(),
7262                stem,
7263                handover: None,
7264            });
7265        }
7266
7267        if !renudged.is_empty() {
7268            state.event(
7269                ctx.node,
7270                format!("retry {round}: re-asking {}", renudged.join(", ")),
7271            );
7272        }
7273        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7274        let mut still = Vec::new();
7275        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7276            seats[i] = seat;
7277            let class = FailClass::of(&out);
7278            // A dropped stream is nudged first (the conversation is still
7279            // there to pick up); only a seat whose nudges are spent hands over.
7280            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7281                && nudges[i] < retries
7282                && !roster.is_empty();
7283            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7284                let next = should_hand_over(prev[i].as_ref(), &cur)
7285                    .then(|| next_agent(i, &tried[i], &specs))
7286                    .flatten();
7287                if carry {
7288                    let h = state
7289                        .seat_history
7290                        .entry(originals[i].seat.key.clone())
7291                        .or_default();
7292                    h.failed.insert(specs[i].id.clone());
7293                    h.last_fail = Some(cur.clone());
7294                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7295                        h.last_ok = None;
7296                    }
7297                    // Saved before the next agent is asked, so a restart in
7298                    // between does not forget who failed.
7299                    if let Err(e) = state.save() {
7300                        tracing::warn!("could not persist a seat's failure history: {e:#}");
7301                    }
7302                }
7303                if let Some(next) = next {
7304                    record_handover(
7305                        state,
7306                        ctx.node,
7307                        &originals[i].seat.key,
7308                        &specs[i].id,
7309                        &next.id,
7310                        &cur,
7311                        &fail_reason(&out),
7312                    );
7313                    tried[i].insert(next.id.clone());
7314                    prev[i] = Some(cur.clone());
7315                    seats[i] =
7316                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7317                    specs[i] = next;
7318                    fresh[i] = Some(cur.stem_word().to_owned());
7319                    nudges[i] = 0;
7320                    done[i] = Some(Err(anyhow::anyhow!(
7321                        "handed over after: {}",
7322                        fail_reason(&out)
7323                    )));
7324                    still.push(i);
7325                    continue;
7326                }
7327            }
7328            if carry
7329                && !nudge_first
7330                && let Some(cur) = class.clone()
7331            {
7332                // The chain ended here (no successor, or a repeated failure).
7333                let h = state
7334                    .seat_history
7335                    .entry(originals[i].seat.key.clone())
7336                    .or_default();
7337                h.failed.insert(specs[i].id.clone());
7338                h.last_fail = Some(cur);
7339            }
7340            let parsed = match out {
7341                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7342                AgentOutcome::Quota(o) => {
7343                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7344                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7345                }
7346                // Not a parseable answer: the nudge loop re-asks it, which is
7347                // exactly what a dropped stream needs. Just don't hand its raw
7348                // error JSON to `extract_json`.
7349                AgentOutcome::Dropped(o) => {
7350                    let why = o
7351                        .dropped
7352                        .as_ref()
7353                        .map(|d| d.why.as_str())
7354                        .unwrap_or("the CLI ended the stream without delivering its answer");
7355                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7356                }
7357                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7358            };
7359            let quota = class == Some(FailClass::Quota);
7360            let failed = parsed.is_err();
7361            done[i] = Some(parsed);
7362            if carry && !failed {
7363                let h = state
7364                    .seat_history
7365                    .entry(originals[i].seat.key.clone())
7366                    .or_default();
7367                h.failed.remove(&specs[i].id);
7368                h.last_ok = Some(specs[i].id.clone());
7369                h.last_fail = None;
7370            }
7371            // Do not re-ask a rate-limited seat (quota) — a retry is known to
7372            // fail the same way; and never re-ask a seat that already parsed.
7373            // A failed agent that still has a successor is not re-asked
7374            // either: the handover was its remedy and has just been refused
7375            // (the chain stops on a repeated failure class). A seat with no
7376            // successor left (a single-agent roster, the roster's tail, or an
7377            // empty roster) keeps the same-agent nudge, bounded by `retries`.
7378            let agent_failure = class.is_some()
7379                && !nudge_first
7380                && !roster.is_empty()
7381                && next_agent(i, &tried[i], &specs).is_some();
7382            if failed && !quota && !agent_failure && nudges[i] < retries {
7383                nudges[i] += 1;
7384                still.push(i);
7385            }
7386        }
7387        pending = still;
7388    }
7389
7390    // One loss per seat whose chain ended on a quota: the intermediate ones
7391    // were absorbed by a handover and are not losses.
7392    for (i, q) in last_quota.into_iter().enumerate() {
7393        if let Some(reset) = q {
7394            losses.push(QuotaLoss {
7395                seat: originals[i].seat.key.clone(),
7396                node: ctx.node.to_owned(),
7397                at: Timestamp::now(),
7398                reset,
7399            });
7400        }
7401    }
7402
7403    seats
7404        .into_iter()
7405        .zip(done)
7406        .zip(nudges)
7407        .map(|((seat, res), attempts)| {
7408            (
7409                seat,
7410                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7411                attempts,
7412            )
7413        })
7414        .collect()
7415}
7416
7417/// Acquire the shared build cache's lease, waiting out contention within
7418/// `budget` (never past it — see AGENTS.md's build-cache section on why an
7419/// unbounded wait is never acceptable).
7420///
7421/// A first, non-blocking check happens before ever waiting; if it finds the
7422/// lease busy, that fact is logged as a `verify` event *and* flushed with
7423/// [`RunState::save`] immediately — not only once the wait finally succeeds
7424/// or gives up — so a `magi show` run by a different process while this one
7425/// is still waiting reads a `run.json` that says so, rather than whatever it
7426/// looked like before the wait started. The same applies to the terminal
7427/// failure: logged and saved before this returns `Err`, so a caller that
7428/// could not get the lease at all still leaves a legible record of why.
7429async fn acquire_cache_lease(
7430    state: &mut RunState,
7431    cache_dir: &Path,
7432    owner: &crate::cache::Owner,
7433    budget: Duration,
7434    context: &str,
7435) -> Result<crate::cache::Guard> {
7436    let home = crate::run::home();
7437    let started = Instant::now();
7438    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7439        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7440        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7441        Err(e) => {
7442            state.event(
7443                "verify",
7444                format!("{context}: could not check the shared build cache: {e:#}"),
7445            );
7446            if let Err(e2) = state.save() {
7447                tracing::warn!("could not persist a cache-check failure: {e2:#}");
7448            }
7449            return Err(e);
7450        }
7451    };
7452    state.event(
7453        "verify",
7454        format!(
7455            "{context}: waiting for the shared build cache at {} ({})",
7456            cache_dir.display(),
7457            busy.describe()
7458        ),
7459    );
7460    if let Err(e) = state.save() {
7461        tracing::warn!("could not persist a cache wait: {e:#}");
7462    }
7463    let remaining = budget.saturating_sub(started.elapsed());
7464    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7465        Ok(g) => Ok(g),
7466        Err(e) => {
7467            state.event("verify", format!("{context}: {e:#}"));
7468            if let Err(e2) = state.save() {
7469                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7470            }
7471            Err(e)
7472        }
7473    }
7474}
7475
7476/// Run `body` — a verify command batch — while holding the shared build
7477/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
7478/// never interleave with another borrower's build against the same
7479/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
7480/// timeout, or a human's own `magi review`. See the `cache` module doc for
7481/// why this matters more than Cargo's own per-target locking covers — two
7482/// *different* worktrees building the same package name/version into one
7483/// cache directory is a staleness bug, not a lock contention one.
7484///
7485/// The wait for the lease is carved out of `budget`, never on top of it —
7486/// `body` is handed whatever is left, so a caller's own node timeout is the
7487/// only clock involved, exactly what AGENTS.md's build-cache section asks
7488/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
7489/// cache configured at all — this is a pass-through: `body` runs with the
7490/// full budget and nothing is leased.
7491///
7492/// A lease that cannot be acquired within `budget` is reported as a single
7493/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
7494/// verification — the same shape a spawn failure already takes in
7495/// [`run_commands`], so a caller need not special-case it.
7496#[allow(clippy::too_many_arguments)]
7497async fn with_cache_lease<'s, F, Fut>(
7498    state: &'s mut RunState,
7499    cache_dir: Option<&Path>,
7500    node: &str,
7501    seat: &str,
7502    worktree: &Path,
7503    head: &str,
7504    budget: Duration,
7505    context: &str,
7506    body: F,
7507) -> (Vec<CommandOutcome>, bool)
7508where
7509    F: FnOnce(&'s mut RunState, Duration) -> Fut,
7510    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
7511{
7512    let Some(cache_dir) = cache_dir else {
7513        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
7514        return (outcomes, retried);
7515    };
7516    let home = crate::run::home();
7517    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
7518    let started = Instant::now();
7519    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
7520        Ok(g) => g,
7521        Err(e) => {
7522            return (
7523                vec![CommandOutcome {
7524                    command: "(waiting for the shared build cache)".to_owned(),
7525                    code: None,
7526                    output_tail: e.to_string(),
7527                    duration_ms: started.elapsed().as_millis() as u64,
7528                    resource_blocked: true,
7529                }],
7530                false,
7531            );
7532        }
7533    };
7534    let identity = crate::cache::Identity::new(worktree, head);
7535    if let Err(e) = crate::cache::ensure_fresh(&home, cache_dir, &identity) {
7536        // A failed freshness check means this process cannot vouch for what
7537        // is sitting in the cache right now - on Windows this is exactly the
7538        // "a stale test executable is still locked, `cargo clean -p` cannot
7539        // remove it" case the evidence log records. Running verify anyway
7540        // and reporting whatever it says would let a result nobody can trust
7541        // stand for the tree it claims to have checked; fail the step
7542        // instead of the patch.
7543        state.event(
7544            "verify",
7545            format!(
7546                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
7547                worktree.display(),
7548                short(head)
7549            ),
7550        );
7551        guard.release();
7552        return (
7553            vec![CommandOutcome {
7554                command: "(confirming the shared build cache is fresh)".to_owned(),
7555                code: None,
7556                output_tail: e.to_string(),
7557                duration_ms: started.elapsed().as_millis() as u64,
7558                resource_blocked: true,
7559            }],
7560            false,
7561        );
7562    }
7563    let remaining = budget.saturating_sub(started.elapsed());
7564    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
7565    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
7566    // `start_kill`); confirm it actually has before handing the directory to
7567    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
7568    // for what this can and cannot see.
7569    if !timed_out_pids.is_empty() {
7570        wait_for_timed_out_children_to_die(&timed_out_pids).await;
7571    }
7572    guard.release();
7573    (outcomes, retried)
7574}
7575
7576/// Poll `pids` — commands [`run_commands`] reports as still running when its
7577/// own timeout elapsed — until every one is confirmed gone, or
7578/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
7579///
7580/// Real confirmation where confirmation is possible, not a substitute for
7581/// full process-tree observation: a grandchild the timed-out process spawned
7582/// and that survives independently of it is invisible to a pid check the
7583/// same way it always was, and continuing to observe and collect *that*
7584/// stays a different piece of work with its own owner. This only narrows a
7585/// fixed blind wait into an actual check of the pids this process does know
7586/// about.
7587async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
7588    wait_for_pids_with(
7589        pids,
7590        crate::proc::pid_alive,
7591        LEASE_RELEASE_POLL,
7592        LEASE_RELEASE_MAX_WAIT,
7593    )
7594    .await;
7595}
7596
7597/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
7598/// interval and ceiling supplied by the caller, so the polling *logic* -
7599/// returns as soon as every pid reports dead, gives up at the ceiling
7600/// otherwise - is testable on millisecond durations without asking the real
7601/// OS about a pid at all.
7602async fn wait_for_pids_with<F: Fn(u32) -> bool>(
7603    pids: &[u32],
7604    alive: F,
7605    poll: Duration,
7606    max_wait: Duration,
7607) {
7608    let deadline = Instant::now() + max_wait;
7609    loop {
7610        if pids.iter().all(|&pid| !alive(pid)) {
7611            return;
7612        }
7613        if Instant::now() >= deadline {
7614            return;
7615        }
7616        tokio::time::sleep(poll).await;
7617    }
7618}
7619
7620/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
7621/// admission that it could not even get a verify command to run, as opposed
7622/// to evidence the command actually produced? A caller that would otherwise
7623/// read a resource-blocked outcome as a red command must check this first:
7624/// see [`Runner::gate`], which retries rather than records `Blocked` when
7625/// this is true.
7626fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
7627    outcomes.iter().any(|o| o.resource_blocked)
7628}
7629
7630/// What [`Runner::gate_fix_round`] decided.
7631enum GateFix {
7632    /// The tree changed and `verify.e2e` is still green: run the gate again.
7633    Retry,
7634    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
7635    /// last failure stands and the run ends blocked.
7636    Stop,
7637    /// `verify.e2e` could not run after the fix (magi's own contention):
7638    /// decide nothing now, a later reentry retries.
7639    Defer,
7640}
7641
7642/// Is every red command in `outcomes` an ordinary failure the code could
7643/// explain: it ran, exited non-zero, and said something?
7644///
7645/// A timeout, a spawn failure and a killed process all leave `code` `None`;
7646/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
7647/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
7648/// what the command is stays the gate's business.
7649fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
7650    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
7651    red.peek().is_some()
7652        && red.all(|o| {
7653            !o.resource_blocked
7654                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
7655                && !o.output_tail.trim().is_empty()
7656        })
7657}
7658
7659/// Describe one verify command's outcome for the event log, distinguishing a
7660/// build/link failure — the toolchain never produced a binary to run — from
7661/// an actual test failure, since only the latter is a verdict on the patch.
7662fn e2e_outcome_label(o: &CommandOutcome) -> String {
7663    if o.ok() {
7664        return "pass".to_owned();
7665    }
7666    let reason = if o.build_failed() {
7667        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
7668    } else {
7669        format!("FAIL ({:?})", o.code)
7670    };
7671    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
7672}
7673
7674/// Run `verify.e2e`, retrying once if the first attempt could not build or
7675/// link — a build/link failure is frequently a race against a shared
7676/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
7677/// `verify` event per command, tagged with `context` (normally `"round N"`)
7678/// so the two call sites that need this — the ordinary per-round leg in
7679/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
7680/// it will ever call a round green — read identically in the event log.
7681async fn run_e2e_with_retry(
7682    state: &mut RunState,
7683    shell: &[String],
7684    commands: &[String],
7685    worktree: &Path,
7686    timeout: Duration,
7687    context: &str,
7688) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
7689    let (mut e2e, mut timed_out_pids) = run_commands(
7690        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
7691    )
7692    .await;
7693    for o in &e2e {
7694        state.event(
7695            "verify",
7696            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
7697        );
7698    }
7699    // A build/link failure is not a verdict on the patch — it is frequently a
7700    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
7701    // one retry before letting a red like that decide the round.
7702    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
7703    if verify_retried {
7704        state.event(
7705            "verify",
7706            format!(
7707                "{context}: verify could not build/link, not a test result — retrying once \
7708                 before concluding"
7709            ),
7710        );
7711        let retried = run_commands(
7712            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
7713        )
7714        .await;
7715        e2e = retried.0;
7716        // Both attempts' timeouts matter, not just the last one: the first
7717        // attempt's descendants may still be alive alongside the retry's.
7718        timed_out_pids.extend(retried.1);
7719        for o in &e2e {
7720            state.event(
7721                "verify",
7722                format!(
7723                    "{context}: retry `{}` -> {}",
7724                    o.command,
7725                    e2e_outcome_label(o)
7726                ),
7727            );
7728        }
7729    }
7730    (e2e, verify_retried, timed_out_pids)
7731}
7732
7733/// Run configured shell commands in `cwd`, in order. The second element is
7734/// the pid of every command that hit `timeout` and was still running when
7735/// this stopped waiting on it (best-effort: `None` when the platform did not
7736/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
7737/// that releases a shared resource afterward needs to know.
7738///
7739/// Records `task` into [`RunState::active`] at every command boundary
7740/// (`RunState::task_command`) and clears it once the whole list has run
7741/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
7742/// for minutes with no seat and no output of its own to show for it (see
7743/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
7744/// apart from "not yet run" without this), and this is the only place that
7745/// knows which command is running right now and how many are left. Three
7746/// saves per command — start, not per second — matching the same "only at a
7747/// boundary" rule [`wave`] already follows for seats.
7748#[allow(clippy::too_many_arguments)]
7749async fn run_commands(
7750    state: &mut RunState,
7751    node: &str,
7752    task: &str,
7753    attempt: usize,
7754    shell: &[String],
7755    commands: &[String],
7756    cwd: &Path,
7757    timeout: Duration,
7758) -> (Vec<CommandOutcome>, Vec<u32>) {
7759    if commands.is_empty() {
7760        // Nothing to mark as running and nothing to clear — an empty list
7761        // means "not configured", and touching `active` (or the disk) over
7762        // that would be a write for every round of a repo with no
7763        // `verify.e2e` / `verify.gate` commands at all.
7764        return (Vec::new(), Vec::new());
7765    }
7766    let mut out = Vec::new();
7767    let mut timed_out_pids = Vec::new();
7768    let total = commands.len();
7769    for (idx, command) in commands.iter().enumerate() {
7770        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
7771        if let Err(e) = state.save() {
7772            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
7773        }
7774        let started = Instant::now();
7775        let mut cmd = tokio::process::Command::new(&shell[0]);
7776        cmd.quiet();
7777        cmd.args(&shell[1..])
7778            .arg(command)
7779            .current_dir(cwd)
7780            .stdin(std::process::Stdio::null())
7781            .stdout(std::process::Stdio::piped())
7782            .stderr(std::process::Stdio::piped())
7783            .kill_on_drop(true);
7784        let spawned = cmd.spawn();
7785        let (code, body) = match spawned {
7786            Ok(child) => {
7787                // Captured before the child is consumed below: `kill_on_drop`
7788                // only *asks* the process to die when the timeout branch
7789                // drops it, and the pid is the only way anyone downstream can
7790                // later check whether that request actually took.
7791                let pid = child.id();
7792                match tokio::time::timeout(timeout, child.wait_with_output()).await {
7793                    Ok(Ok(o)) => {
7794                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
7795                        body.push_str(&String::from_utf8_lossy(&o.stderr));
7796                        (o.status.code(), body)
7797                    }
7798                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
7799                    Err(_) => {
7800                        if let Some(pid) = pid {
7801                            timed_out_pids.push(pid);
7802                        }
7803                        (None, format!("timed out after {}s", timeout.as_secs()))
7804                    }
7805                }
7806            }
7807            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
7808        };
7809        out.push(CommandOutcome {
7810            command: command.clone(),
7811            code,
7812            output_tail: tail(&body, OUTPUT_TAIL),
7813            duration_ms: started.elapsed().as_millis() as u64,
7814            resource_blocked: false,
7815        });
7816    }
7817    state.task_finished(task);
7818    if let Err(e) = state.save() {
7819        tracing::warn!("could not persist the end of {task}: {e:#}");
7820    }
7821    (out, timed_out_pids)
7822}
7823
7824/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
7825/// section (`report::run`) and in the `merge` event this node records — for
7826/// the operator to run by hand.
7827///
7828/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
7829/// branch whose ruleset forbids merge commits (GitHub's "must not contain
7830/// merge commits", or "require linear history") rejects the push a `--no-ff`
7831/// merge would produce, which is exactly the guidance this function replaces.
7832/// `message`'s first line becomes the squash commit's subject, matching the
7833/// note `report::run` prints alongside this command — see that function for
7834/// why an explicit subject is not optional there.
7835fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
7836    let repo = repo.display();
7837    match style {
7838        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
7839        MergeStyle::Squash => {
7840            // The subject sits inside double quotes, and a title an agent
7841            // wrote may carry the characters that break out of them.
7842            let subject = message
7843                .lines()
7844                .next()
7845                .unwrap_or(branch)
7846                .replace(['\\', '"', '$', '`'], "");
7847            format!(
7848                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
7849            )
7850        }
7851        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
7852    }
7853}
7854
7855/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
7856/// under the hood, rejects a `title` over 256 characters and the whole
7857/// command fails — no PR at all, for a run whose body was otherwise fine
7858/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
7859/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
7860/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
7861/// this bound. It is a margin, not a guarantee — a title packed
7862/// with multi-unit characters could still in principle land close to the
7863/// edge, but a real task title's occasional emoji or accented letter fits
7864/// comfortably inside it.
7865const PR_TITLE_MAX: usize = 240;
7866
7867/// A pull request title from the opening line of `text`, or `None` when that
7868/// line is not English (GitHub text is) or has no letters.
7869///
7870/// A line within `max` is kept as is. A longer one is cut at the end of its
7871/// first sentence when that falls inside `max`, else at a word boundary with a
7872/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
7873/// make every merely-truncated title look non-English). The language check
7874/// runs on the kept text before any mark is added, so only what GitHub will
7875/// show is judged: an English opening followed by non-ASCII far past the cut
7876/// still passes.
7877fn english_title(text: &str, max: usize) -> Option<String> {
7878    let line = queue::first_line(text)?;
7879    let chars: Vec<char> = line.chars().collect();
7880    let (kept, mark) = if chars.len() <= max {
7881        (line.to_owned(), "")
7882    } else if let Some(end) = sentence_end(&chars, max) {
7883        (chars[..end].iter().collect::<String>(), "")
7884    } else {
7885        let room = max.saturating_sub(3);
7886        // Cut at the last space inside the room; when the char just past the
7887        // room is a space the room already ends on a word.
7888        let cut = if chars[room].is_whitespace() {
7889            room
7890        } else {
7891            chars[..room]
7892                .iter()
7893                .rposition(|c| c.is_whitespace())
7894                .unwrap_or(room)
7895        };
7896        let head: String = chars[..cut].iter().collect();
7897        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
7898        (head.to_owned(), "...")
7899    };
7900    if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
7901        return None;
7902    }
7903    Some(format!("{kept}{mark}"))
7904}
7905
7906/// The char length of the first sentence of `chars` when it ends within `max`
7907/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
7908/// abbreviations so `e.g. foo` does not end a title early.
7909fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
7910    const MIN: usize = 20;
7911    for i in MIN..max.min(chars.len()) {
7912        if !matches!(chars[i], '.' | '!' | '?') {
7913            continue;
7914        }
7915        let Some(&next) = chars.get(i + 1) else {
7916            continue;
7917        };
7918        if !next.is_whitespace() {
7919            continue;
7920        }
7921        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
7922        if after.is_some_and(|c| c.is_ascii_lowercase()) {
7923            continue;
7924        }
7925        let word: String = chars[..i]
7926            .iter()
7927            .rev()
7928            .take_while(|c| !c.is_whitespace())
7929            .collect::<Vec<_>>()
7930            .into_iter()
7931            .rev()
7932            .collect();
7933        let word = word.to_ascii_lowercase();
7934        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
7935            continue;
7936        }
7937        let end = chars[..i]
7938            .iter()
7939            .rposition(|c| !c.is_whitespace())
7940            .map_or(i, |p| p + 1);
7941        return Some(end);
7942    }
7943    None
7944}
7945
7946/// What `merge = "pr"` (and the merge commit of the other modes) says about a
7947/// change: a title and a body describing what was *implemented*, not the task
7948/// that asked for it. A task reads as a request; a reader of the merged
7949/// history wants the change.
7950struct PrMessage {
7951    title: String,
7952    body: String,
7953}
7954
7955impl PrMessage {
7956    /// Title, blank line, body. The first line is the squash/merge commit
7957    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
7958    /// has to stay one sensible line.
7959    fn commit_message(&self) -> String {
7960        format!("{}\n\n{}", self.title, self.body)
7961    }
7962}
7963
7964/// The text after a leading `TITLE:` (any case) on `line`.
7965fn title_marker(line: &str) -> Option<&str> {
7966    let line = line.trim();
7967    let head = line.get(..6)?;
7968    head.eq_ignore_ascii_case("title:")
7969        .then(|| line[6..].trim())
7970}
7971
7972/// The implementer's own one-line title: the `TITLE:` line the implement
7973/// prompt asks for at the top of its SUMMARY. Candidate commits are all
7974/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
7975/// source, and a title that says as much is refused here too.
7976fn summary_title(summary: &str) -> Option<String> {
7977    let first = summary.lines().find(|l| !l.trim().is_empty())?;
7978    let raw = title_marker(first)?;
7979    if raw.is_empty() {
7980        return None;
7981    }
7982    let title = queue::title_from(raw, PR_TITLE_MAX);
7983    let lower = title.to_ascii_lowercase();
7984    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
7985        return None;
7986    }
7987    Some(title)
7988}
7989
7990/// How `open_review`'s instruction begins; see [`landing_title`].
7991const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
7992
7993/// Marker `open_review` gives a candidate that nothing in the roster wrote.
7994const EXISTING_BRANCH: &str = "(existing branch)";
7995
7996/// Does this run review work that already existed, rather than implement a
7997/// task? Runs recorded before `reviewed_commits` existed carry only the
7998/// candidate marker.
7999fn is_review_run(state: &RunState) -> bool {
8000    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8001}
8002
8003/// The title of a review-only run: the subject of the oldest commit under
8004/// review. Later commits are usually fixups, and `instruction` is the review
8005/// prompt, which says nothing about the change. GitHub text is English, so a
8006/// non-ASCII or blank subject yields `None` and the caller's neutral title.
8007fn review_title(state: &RunState) -> Option<String> {
8008    english_subject(state.reviewed_commits.as_ref()?.first()?)
8009}
8010
8011/// `raw` as a pull request title, or `None` when it is blank, not English
8012/// (GitHub text is), or one of magi's own candidate commit subjects.
8013fn english_subject(raw: &str) -> Option<String> {
8014    let raw = raw.trim();
8015    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8016        return None;
8017    }
8018    let title = queue::title_from(raw, PR_TITLE_MAX);
8019    let lower = title.to_ascii_lowercase();
8020    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8021        return None;
8022    }
8023    Some(title)
8024}
8025
8026/// What a review-only run's branch says about itself, read at the moment the
8027/// pull request is opened.
8028#[derive(Debug, Clone, PartialEq, Eq)]
8029struct BranchFacts {
8030    /// `(subject, body)` of each commit, oldest first.
8031    commits: Vec<(String, String)>,
8032    /// Trimmed `git diff --stat`.
8033    stat: String,
8034}
8035
8036/// Longest diff stat shown: this many file lines plus the summary line.
8037const STAT_FILE_LINES: usize = 25;
8038/// Cap on the commit list, well inside GitHub's 65536-character body limit.
8039const COMMITS_MAX_CHARS: usize = 20_000;
8040
8041/// Read the commits and diff stat of `base..branch`. `None` when git cannot
8042/// say or finds nothing, so the caller falls back to what the run recorded.
8043async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8044    let commits = git::commit_log(repo, base, branch).await.ok()?;
8045    if commits.is_empty() {
8046        return None;
8047    }
8048    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8049    let lines: Vec<&str> = stat.lines().collect();
8050    let stat = if lines.len() > STAT_FILE_LINES + 1 {
8051        let omitted = lines.len() - 1 - STAT_FILE_LINES;
8052        let more = format!(" ... {omitted} more file(s)");
8053        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8054        kept.push(&more);
8055        kept.push(lines[lines.len() - 1]);
8056        kept.join("\n")
8057    } else {
8058        lines.join("\n")
8059    };
8060    Some(BranchFacts { commits, stat })
8061}
8062
8063/// Defang what would break the surrounding markdown: a closing `</details>`
8064/// and a code fence.
8065fn markdown_safe(text: &str) -> String {
8066    text.replace("</details>", "&lt;/details&gt;")
8067        .replace("\x60\x60\x60", "~~~")
8068}
8069
8070fn neutral_title(state: &RunState, winner: char) -> String {
8071    format!(
8072        "chore: land candidate {} of run {}",
8073        winner.to_ascii_uppercase(),
8074        state.id
8075    )
8076}
8077
8078/// The pull request title to hand to `land::merge_subject`. A review-only run
8079/// opened by an earlier build titled its pull request with the review prompt;
8080/// that title is dropped (empty, so the fallback applies) rather than landed.
8081/// Any other title, including an operator's rename, passes through untouched,
8082/// and so does every title of a run that implements a task.
8083pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8084    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8085        ""
8086    } else {
8087        pr_title
8088    }
8089}
8090
8091/// What the squash subject falls back to when the pull request title is empty
8092/// or candidate-shaped: for a review-only run the derived title, never the
8093/// review prompt held in `instruction`.
8094pub fn landing_subject_source(state: &RunState) -> String {
8095    if is_review_run(state) {
8096        let winner = state.candidates.first().map_or('A', |c| c.label);
8097        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8098    }
8099    state.instruction.clone()
8100}
8101
8102/// `summary` without its `TITLE:` line, which the pull request title already
8103/// carries.
8104fn summary_without_title(summary: &str) -> String {
8105    let mut lines = summary.trim().lines().peekable();
8106    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8107        lines.next();
8108    }
8109    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8110}
8111
8112/// The pull request title and body for the winning candidate.
8113///
8114/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
8115/// the task's own opening line via [`queue::title_from`] when there is none.
8116/// `state.instruction` can open with blank lines (`task_text` only rejects a
8117/// body that is blank *entirely*), which `title_from` skips.
8118///
8119/// Body: the implementer's summary and the fixer's notes, then — when the
8120/// winning review round was not clean — the findings still open and whatever
8121/// the fixer declined, so `merge = "pr"` hands the reader the same material
8122/// `magi show` does. The task follows inside a collapsed block, and the
8123/// footer repeats the run and candidate as plain tags for a reader holding
8124/// only the merged commit or the PR body.
8125#[cfg(test)]
8126fn pr_message(state: &RunState, winner: char) -> PrMessage {
8127    pr_message_with(state, winner, None)
8128}
8129
8130/// [`pr_message`] with what the branch of a review-only run says about itself.
8131/// `facts` is ignored for a run that implements a task.
8132fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8133    let summary = state
8134        .candidates
8135        .iter()
8136        .find(|c| c.label == winner)
8137        .map(|c| c.summary.as_str())
8138        .unwrap_or_default();
8139    // The fallback is the operator's own words and may not be English; GitHub
8140    // text always is, so a non-English task gets a neutral title instead.
8141    let review = is_review_run(state);
8142    let title = if review {
8143        facts
8144            .and_then(|f| english_subject(&f.commits.first()?.0))
8145            .or_else(|| review_title(state))
8146            .or_else(|| {
8147                state
8148                    .candidates
8149                    .iter()
8150                    .find(|c| c.label == winner)
8151                    .filter(|c| !c.branch.starts_with("magi/"))
8152                    .and_then(|c| english_subject(&c.branch))
8153            })
8154            .unwrap_or_else(|| neutral_title(state, winner))
8155    } else {
8156        summary_title(summary).unwrap_or_else(|| {
8157            english_title(&state.instruction, PR_TITLE_MAX)
8158                .unwrap_or_else(|| neutral_title(state, winner))
8159        })
8160    };
8161
8162    let mut body = String::new();
8163    let what = summary_without_title(summary);
8164    if !what.is_empty() {
8165        body.push_str("## Summary\n\n");
8166        body.push_str(&what);
8167        body.push_str("\n\n");
8168    }
8169
8170    // The last round is usually a clean verification pass with no fix of its
8171    // own, so every round's notes are read, not just the final one's.
8172    let notes: Vec<(usize, &str)> = state
8173        .reviews
8174        .iter()
8175        .filter_map(|r| {
8176            let n = r.fix.as_ref()?.notes.trim();
8177            (!n.is_empty()).then_some((r.round, n))
8178        })
8179        .collect();
8180    if !notes.is_empty() {
8181        body.push_str("## Review fixes\n\n");
8182        if let [(_, only)] = notes.as_slice() {
8183            body.push_str(only);
8184            body.push_str("\n\n");
8185        } else {
8186            for (round, n) in &notes {
8187                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8188            }
8189        }
8190    }
8191    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8192
8193    let open = state.open_findings();
8194    if !open.is_empty() {
8195        body.push_str("## Open review findings\n\n");
8196        for f in &open {
8197            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8198        }
8199        body.push('\n');
8200    }
8201
8202    if let Some(fix) = fix
8203        && !fix.rejected.is_empty()
8204    {
8205        body.push_str("## Declined by the fixer\n\n");
8206        for r in &fix.rejected {
8207            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8208        }
8209        body.push('\n');
8210    }
8211
8212    if review {
8213        // The review prompt is not the task; list what the branch carries.
8214        body.push_str("## Commits under review\n\n");
8215        if let Some(facts) = facts {
8216            let mut left = COMMITS_MAX_CHARS;
8217            for (i, (subject, text)) in facts.commits.iter().enumerate() {
8218                let mut entry = format!("- {}\n", markdown_safe(subject));
8219                for l in markdown_safe(text).lines() {
8220                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
8221                }
8222                if left == 0 {
8223                    body.push_str(&format!(
8224                        "- ... {} more commit(s)\n",
8225                        facts.commits.len() - i
8226                    ));
8227                    break;
8228                }
8229                if entry.len() > left {
8230                    // Even the first commit is cut: one huge body must not
8231                    // push the whole description past GitHub's limit.
8232                    let mut end = left;
8233                    while !entry.is_char_boundary(end) {
8234                        end -= 1;
8235                    }
8236                    entry.truncate(end);
8237                    entry.push_str("\n  ... (truncated)\n");
8238                    left = 0;
8239                } else {
8240                    left -= entry.len();
8241                }
8242                body.push_str(&entry);
8243            }
8244            if !facts.stat.trim().is_empty() {
8245                body.push_str(&format!(
8246                    "\n## Diff stat\n\n```\n{}\n```\n",
8247                    markdown_safe(facts.stat.trim())
8248                ));
8249            }
8250        } else {
8251            match &state.reviewed_commits {
8252                Some(subjects) => {
8253                    for s in subjects {
8254                        body.push_str(&format!("- {}\n", s.trim()));
8255                    }
8256                }
8257                None => {
8258                    // An older run kept only the prompt, with the commit list
8259                    // after its first paragraph.
8260                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8261                    body.push_str(rest.trim());
8262                    body.push('\n');
8263                }
8264            }
8265        }
8266    } else {
8267        let task = state.instruction.trim();
8268        let task = if task.is_empty() {
8269            "(empty task)"
8270        } else {
8271            task
8272        };
8273        body.push_str(&format!(
8274            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8275            task.replace("</details>", "&lt;/details&gt;")
8276        ));
8277    }
8278
8279    body.push_str(&format!(
8280        "\n---\nmagi:run/{} magi:candidate-{}\n",
8281        state.id,
8282        winner.to_ascii_lowercase()
8283    ));
8284
8285    // Prompts are advisory; this is the enforced half of the confidentiality
8286    // rule, and it covers the verbatim task in <details> too.
8287    let id = crate::scrub::Identity::current();
8288    PrMessage {
8289        title: crate::scrub::scrub(&title, &id),
8290        body: crate::scrub::scrub(&body, &id),
8291    }
8292}
8293
8294/// The task with what the repository says about the existing work it names
8295/// appended, so an implementer knows what it started from and what it must
8296/// not redo. Unchanged when the task names nothing.
8297fn seeded_instruction(state: &RunState) -> String {
8298    match refs::describe(&state.seeds) {
8299        Some(facts) => format!(
8300            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8301             Candidates start from the unmerged branch named above, when there \
8302             is one, and carry any unmerged commit named by sha as a \
8303             cherry-pick. Check that this is what the task meant before \
8304             building on it.",
8305            state.instruction
8306        ),
8307        None => state.instruction.clone(),
8308    }
8309}
8310
8311/// Does the winner have no commits ahead of the base it would land on?
8312/// Any failure to find out reads as "not empty": the merge then behaves as it
8313/// always did rather than refusing on a guess.
8314async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8315    let base = &state.base_branch;
8316    let mut against = base.clone();
8317    if mode == MergeMode::Pr {
8318        // A pull request lands on the remote's base, never the local branch
8319        // of the same name: if that cannot be read, "not empty" is the safe
8320        // answer.
8321        let remote = &state.config.merge.remote;
8322        let tracking = format!("{remote}/{base}");
8323        let fetched = git::fetch(repo, remote, base).await;
8324        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8325            against = tracking;
8326        } else {
8327            return false;
8328        }
8329    }
8330    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8331}
8332
8333/// Why nothing was opened for an empty winner, with what the task's own
8334/// references resolved to.
8335fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8336    let mut detail = format!(
8337        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8338         nothing to open a pull request for"
8339    );
8340    match refs::describe(&state.seeds) {
8341        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8342        None => detail.push_str(
8343            "\nThe task names no existing branch or commit; if it means to land work \
8344             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8345        ),
8346    }
8347    detail
8348}
8349
8350/// What the `Pr` merge does once it knows whether the branch already has an
8351/// open pull request.
8352#[derive(Debug, PartialEq, Eq)]
8353enum PrPlan {
8354    Create,
8355    Adopt { url: String, title: String },
8356    Stop(String),
8357}
8358
8359/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
8360/// a failed lookup -> stop with the real reason. Never guesses.
8361fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8362    match found {
8363        Ok(land::OpenPr::None) => PrPlan::Create,
8364        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8365        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8366            "several open pull requests exist for this branch, not picking one: {}",
8367            urls.join(" ")
8368        )),
8369        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8370    }
8371}
8372
8373/// `gh pr create`, returning the PR url.
8374async fn gh_pr_create(
8375    cwd: &Path,
8376    base: &str,
8377    head: &str,
8378    title: &str,
8379    body: &str,
8380) -> Result<String> {
8381    let out = tokio::process::Command::new("gh")
8382        .args([
8383            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8384        ])
8385        .current_dir(cwd)
8386        .quiet()
8387        .stdin(std::process::Stdio::null())
8388        .output()
8389        .await
8390        .context("spawn gh")?;
8391    if out.status.success() {
8392        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8393    } else {
8394        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8395    }
8396}
8397
8398/// Tear a run's worktrees and branches down.
8399///
8400/// `home` is where the updated `run.json` is saved (via
8401/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
8402/// a housekeeping pass already has its own honest `home` handed to it, and
8403/// falling through to the global here would write back through whichever
8404/// directory some other process or test pinned into that `OnceLock` first,
8405/// not the one the caller actually resolved its `runs` and `state` from.
8406pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8407    let repo = state.repo.clone();
8408    let root = state.worktree_root();
8409    let winner = state.tally.as_ref().map(|t| t.winner);
8410    let mut removed = Vec::new();
8411
8412    for i in 0..state.candidates.len() {
8413        let c = state.candidates[i].clone();
8414        let is_winner = Some(c.label) == winner;
8415        if is_winner && !drop_winner {
8416            continue;
8417        }
8418        if c.worktree.exists() {
8419            git::worktree_remove(&repo, &c.worktree).await.ok();
8420            removed.push(c.worktree.to_string_lossy().into_owned());
8421        }
8422        // A branch handed to a later run (and its pull request) is not this
8423        // run's to delete.
8424        let handed_over = state.released_branches.contains(&c.branch);
8425        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8426            git::branch_delete(&repo, &c.branch).await.ok();
8427            removed.push(c.branch.clone());
8428        }
8429        state.candidates[i].folded = true;
8430    }
8431
8432    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8433        let path = name.path();
8434        let keep = !drop_winner
8435            && winner.is_some_and(|w| {
8436                path.file_name()
8437                    .is_some_and(|n| n == format!("cand-{w}").as_str())
8438            });
8439        if keep {
8440            continue;
8441        }
8442        git::worktree_remove(&repo, &path).await.ok();
8443        removed.push(path.to_string_lossy().into_owned());
8444    }
8445
8446    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
8447    // judge worktrees, so once the loop above has cleared all of them out,
8448    // the parent is a bare directory nobody else was ever going to remove -
8449    // git only ever managed what was inside it. Left alone, one of these
8450    // accumulates per fully-folded run; the operator's own machine had 74.
8451    // `remove_if_empty` re-checks rather than assuming: a run whose winner
8452    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
8453    // so does anything a run never claimed that happens to share the bay.
8454    remove_if_empty(&root);
8455
8456    if state.enabled_worktree_config && drop_winner {
8457        // A release, not a raw disable: some sibling run in this repository
8458        // may still hold its own reference (see `git::acquire_worktree_config`),
8459        // and only the last release actually turns the setting back off.
8460        git::release_worktree_config(&repo).await.ok();
8461        state.enabled_worktree_config = false;
8462    }
8463    state.save_under(home)?;
8464    Ok(removed)
8465}
8466
8467/// Remove `dir` if it exists and has nothing in it.
8468///
8469/// Best-effort and silent by design: a directory that is not empty (a run
8470/// whose winner is still parked there, a stray file some other process left)
8471/// is exactly the case this must refuse, and a directory that is already gone
8472/// is not a failure worth reporting either. `std::fs::remove_dir` itself
8473/// already refuses a non-empty directory, so the emptiness check below is
8474/// belt, not suspenders - it is what keeps this from ever attempting the
8475/// removal in the case that matters, rather than trusting `remove_dir`'s
8476/// error path to have no side effects if it ever changed.
8477fn remove_if_empty(dir: &Path) {
8478    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
8479        std::fs::remove_dir(dir).ok();
8480    }
8481}
8482
8483/// Severity of the worst open finding in the last review round, for reporting.
8484pub fn worst_open(state: &RunState) -> Option<Severity> {
8485    state
8486        .reviews
8487        .last()?
8488        .reviews
8489        .iter()
8490        .flat_map(|r| r.findings.iter())
8491        .map(|f| f.severity)
8492        .max()
8493}
8494
8495#[cfg(test)]
8496mod tests {
8497    #[test]
8498    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
8499        let leaked = vec!["chore(deps): update a crate".to_owned()];
8500        let own = "fix(daemon): apply a chosen action";
8501        assert!(should_retitle("", own, &leaked));
8502        assert!(should_retitle(
8503            &format!("{REVIEW_PROMPT_OPENING} `x`"),
8504            own,
8505            &leaked
8506        ));
8507        assert!(should_retitle(
8508            "chore: land candidate A of run 1",
8509            own,
8510            &leaked
8511        ));
8512        assert!(should_retitle(
8513            "magi: candidate A (uncommitted work)",
8514            own,
8515            &leaked
8516        ));
8517        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
8518        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
8519        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
8520    }
8521
8522    #[test]
8523    fn pr_merge_plan_creates_adopts_or_stops() {
8524        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
8525        assert_eq!(
8526            pr_merge_plan(Ok(land::OpenPr::One {
8527                url: "u".into(),
8528                title: "t".into()
8529            })),
8530            PrPlan::Adopt {
8531                url: "u".into(),
8532                title: "t".into()
8533            }
8534        );
8535        let PrPlan::Stop(many) =
8536            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
8537        else {
8538            panic!("many must stop");
8539        };
8540        assert!(many.contains('a') && many.contains('b'));
8541        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
8542            panic!("a failed lookup must stop");
8543        };
8544        assert!(err.contains("bad token"));
8545    }
8546
8547    use super::*;
8548    use crate::run::GateStatus;
8549    use std::collections::BTreeMap;
8550    use std::time::Duration;
8551
8552    fn conductor() -> AgentSpec {
8553        AgentSpec {
8554            id: "conductor".to_owned(),
8555            kind: crate::config::AgentKind::Command,
8556            model: None,
8557            command: vec!["true".to_owned()],
8558            extra_args: Vec::new(),
8559            env: BTreeMap::new(),
8560            prompt_delivery: None,
8561        }
8562    }
8563
8564    fn spec(id: &str) -> AgentSpec {
8565        AgentSpec {
8566            id: id.to_owned(),
8567            kind: crate::config::AgentKind::Command,
8568            model: None,
8569            command: vec!["true".to_owned()],
8570            extra_args: Vec::new(),
8571            env: BTreeMap::new(),
8572            prompt_delivery: None,
8573        }
8574    }
8575
8576    fn ids(xs: &[&str]) -> BTreeSet<String> {
8577        xs.iter().map(|s| (*s).to_owned()).collect()
8578    }
8579
8580    #[test]
8581    fn pick_successor_skips_an_agent_another_seat_holds() {
8582        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
8583        let roster = [spec("a"), spec("b"), spec("c")];
8584        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8585        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8586    }
8587
8588    #[test]
8589    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
8590        // The other seat started on c but was handed to d; c is free again.
8591        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
8592        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8593        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8594        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
8595        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
8596    }
8597
8598    #[test]
8599    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
8600        let roster = [spec("a"), spec("b")];
8601        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
8602        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8603        let carried = ids(&["b"]);
8604        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
8605        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8606    }
8607
8608    #[test]
8609    fn pick_successor_returns_none_without_any_untried_successor() {
8610        let roster = [spec("a"), spec("b")];
8611        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
8612        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
8613        let carried = ids(&["a"]);
8614        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
8615    }
8616
8617    #[test]
8618    fn pick_successor_rescue_avoids_another_seats_occupant() {
8619        // b is a carried failure and free; a is held by the other seat.
8620        let roster = [spec("a"), spec("b"), spec("c")];
8621        let carried = ids(&["b", "c"]);
8622        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
8623        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8624    }
8625
8626    #[test]
8627    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
8628        let roster = [spec("a"), spec("b"), spec("c")];
8629        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
8630        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
8631    }
8632
8633    #[test]
8634    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
8635        let roster = [spec("a"), spec("b"), spec("c")];
8636        let failed = ids(&["a", "b", "c"]);
8637        // Rescue looks at the whole roster, once per id, then runs out.
8638        let mut tried = ids(&["b"]);
8639        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8640        assert_eq!(first.id, "a");
8641        tried.insert(first.id.clone());
8642        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
8643        assert_eq!(second.id, "c");
8644        tried.insert(second.id.clone());
8645        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
8646    }
8647
8648    #[test]
8649    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
8650        let roster = [spec("a"), spec("b")];
8651        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
8652        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8653    }
8654
8655    #[test]
8656    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
8657        let roster = [spec("a"), spec("b"), spec("c")];
8658        let h = SeatHistory {
8659            failed: ids(&["a"]),
8660            last_ok: Some("b".to_owned()),
8661            last_fail: None,
8662        };
8663        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
8664        // A last answerer that left the roster, or later failed, is ignored.
8665        let gone = SeatHistory {
8666            last_ok: Some("zzz".to_owned()),
8667            ..h.clone()
8668        };
8669        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
8670        let failed = SeatHistory {
8671            failed: ids(&["a", "b"]),
8672            last_ok: Some("b".to_owned()),
8673            last_fail: None,
8674        };
8675        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
8676    }
8677
8678    #[test]
8679    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
8680        let first = SeatState::new("review-1", "alpha", 7);
8681        let next = handover_seat("review-1", "gamma", 7);
8682        assert_ne!(first.claude_session, next.claude_session);
8683    }
8684
8685    #[test]
8686    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
8687        let mut state = state_with_summary("x", "y");
8688        let a = handover_seat("review-1", "beta", state.next_seat_seed());
8689        let b = handover_seat("review-1", "beta", state.next_seat_seed());
8690        assert_ne!(a.claude_session, b.claude_session);
8691    }
8692
8693    #[test]
8694    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
8695        let roster = [spec("a"), spec("b")];
8696        let h = SeatHistory {
8697            failed: ids(&["a", "b"]),
8698            ..SeatHistory::default()
8699        };
8700        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
8701        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
8702        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
8703    }
8704
8705    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
8706    // fallback loop depends on to terminate: it must walk forward from the
8707    // seat's own position, never restart at the front of the roster, and it
8708    // must never hand back an id already tried, however many times that id
8709    // happens to appear.
8710
8711    #[test]
8712    fn failure_signature_ignores_numbers_and_paths() {
8713        assert_eq!(
8714            failure_signature("exited with Some(2) and no usable output"),
8715            failure_signature("exited with Some(137) and no usable output")
8716        );
8717        assert_eq!(
8718            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
8719            failure_signature("cannot open /var/x.txt: denied")
8720        );
8721        assert_ne!(failure_signature("boom"), failure_signature("bang"));
8722    }
8723
8724    #[test]
8725    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
8726        let other = FailClass::Other("x".into());
8727        assert!(should_hand_over(None, &FailClass::Quota));
8728        assert!(should_hand_over(Some(&other), &FailClass::Quota));
8729        assert!(should_hand_over(
8730            Some(&FailClass::Timeout),
8731            &FailClass::Timeout
8732        ));
8733        assert!(should_hand_over(None, &other));
8734        assert!(!should_hand_over(Some(&other), &other));
8735        assert!(should_hand_over(
8736            Some(&other),
8737            &FailClass::Other("y".into())
8738        ));
8739        // A quota or timeout in between ends the run of identical failures.
8740        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
8741        assert!(should_hand_over(Some(&FailClass::Quota), &other));
8742    }
8743
8744    #[test]
8745    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
8746        let a = SeatState::new("judge-1", "alpha", 7);
8747        let b = handover_seat("judge-1", "beta", 7);
8748        assert_ne!(a.claude_session, b.claude_session);
8749        assert_eq!(b.turns, 0);
8750    }
8751
8752    #[test]
8753    fn a_timeout_is_classified_apart_from_other_failures() {
8754        assert_eq!(
8755            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
8756            Some(FailClass::Timeout)
8757        );
8758        assert!(matches!(
8759            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
8760            Some(FailClass::Other(_))
8761        ));
8762    }
8763
8764    #[test]
8765    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
8766        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8767        let tried = BTreeSet::from(["beta".to_owned()]);
8768        // beta sits at index 1; the next candidate is gamma, never alpha —
8769        // which is very likely a different candidate slot's own agent.
8770        let next = next_untried_in_roster(&roster, 1, &tried);
8771        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
8772    }
8773
8774    #[test]
8775    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
8776        let roster = vec![spec("alpha"), spec("beta")];
8777        let tried = BTreeSet::from(["beta".to_owned()]);
8778        // beta is the roster's last entry: nothing follows it, and alpha —
8779        // earlier in the roster, almost certainly a different candidate
8780        // slot's own agent — must not be reached by wrapping back to it.
8781        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8782    }
8783
8784    #[test]
8785    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
8786        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
8787        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
8788        // beta (index 1) and gamma (index 2, the only entry after it) have
8789        // both been tried; alpha (index 0) never has, but it comes before
8790        // beta's own position, so there is nothing further for this seat.
8791        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
8792    }
8793
8794    #[test]
8795    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
8796        let roster = vec![spec("a"), spec("a"), spec("b")];
8797        let tried = BTreeSet::from(["a".to_owned()]);
8798        let next = next_untried_in_roster(&roster, 0, &tried);
8799        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
8800    }
8801
8802    #[test]
8803    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
8804        let roster = vec![spec("a"), spec("b")];
8805        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
8806        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
8807    }
8808
8809    #[test]
8810    fn remove_if_empty_only_ever_takes_a_bare_directory() {
8811        let dir = tempfile::tempdir().unwrap();
8812        let bay = dir.path().join("ffff");
8813
8814        // Not there yet: nothing to do, nothing to panic on.
8815        remove_if_empty(&bay);
8816        assert!(!bay.exists());
8817
8818        // Something still inside - the winner's worktree, or a stray file -
8819        // keeps the directory standing.
8820        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
8821        remove_if_empty(&bay);
8822        assert!(bay.exists(), "non-empty directory must survive");
8823
8824        // Once the last entry is gone, so is the directory itself.
8825        std::fs::remove_dir(bay.join("cand-A")).unwrap();
8826        remove_if_empty(&bay);
8827        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
8828    }
8829
8830    // `round_is_clean` is the exact decision this task fixed: a round with a
8831    // seat that never answered must not read the same as a round every seat
8832    // actually reviewed. These are deterministic and process-free by design —
8833    // the equivalent end-to-end check (a real reviewer timing out under a
8834    // live graph run) is a genuine race against wall-clock contention, and a
8835    // spawn slow enough to blow even a generous budget under a loaded test
8836    // run must not turn this specific regression check flaky.
8837
8838    #[test]
8839    fn a_full_panel_that_found_nothing_is_clean() {
8840        assert!(round_is_clean(
8841            0,
8842            true,
8843            2,
8844            2,
8845            0,
8846            IncompleteReviewPolicy::Block
8847        ));
8848    }
8849
8850    #[test]
8851    fn a_missing_seat_is_never_clean_under_the_default_policy() {
8852        assert!(!round_is_clean(
8853            0,
8854            true,
8855            1,
8856            2,
8857            0,
8858            IncompleteReviewPolicy::Block
8859        ));
8860    }
8861
8862    #[test]
8863    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
8864        assert!(!round_is_clean(
8865            1,
8866            true,
8867            1,
8868            2,
8869            0,
8870            IncompleteReviewPolicy::Warn
8871        ));
8872    }
8873
8874    #[test]
8875    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
8876        assert!(round_is_clean(
8877            0,
8878            true,
8879            1,
8880            2,
8881            0,
8882            IncompleteReviewPolicy::Warn
8883        ));
8884    }
8885
8886    #[test]
8887    fn a_full_panel_with_an_open_finding_is_not_clean() {
8888        assert!(!round_is_clean(
8889            1,
8890            true,
8891            2,
8892            2,
8893            0,
8894            IncompleteReviewPolicy::Block
8895        ));
8896    }
8897
8898    #[test]
8899    fn a_full_panel_with_a_red_e2e_is_not_clean() {
8900        assert!(!round_is_clean(
8901            0,
8902            false,
8903            2,
8904            2,
8905            0,
8906            IncompleteReviewPolicy::Block
8907        ));
8908    }
8909
8910    // The stall this task closes: under the default `block` policy, a seat
8911    // missing only because it was rate limited must not force a wait for a
8912    // session limit that will not lift by the next round. `round_is_clean`
8913    // is where that quorum carve-out lives; the review loop around it never
8914    // changes what a reviewer's vote or a finding's severity means.
8915
8916    #[test]
8917    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
8918        // 1 of 2 answered, and the one missing was quota'd — the exact
8919        // "review-2 rate limited (quota)" shape from the field report.
8920        assert!(round_is_clean(
8921            0,
8922            true,
8923            1,
8924            2,
8925            1,
8926            IncompleteReviewPolicy::Block
8927        ));
8928    }
8929
8930    #[test]
8931    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
8932        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
8933        // not a quota loss (`quota_missing` stays 0) — worth another try.
8934        assert!(!round_is_clean(
8935            0,
8936            true,
8937            1,
8938            2,
8939            0,
8940            IncompleteReviewPolicy::Block
8941        ));
8942    }
8943
8944    #[test]
8945    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
8946        assert!(!round_is_clean(
8947            1,
8948            true,
8949            1,
8950            2,
8951            1,
8952            IncompleteReviewPolicy::Block
8953        ));
8954        assert!(!round_is_clean(
8955            0,
8956            false,
8957            1,
8958            2,
8959            1,
8960            IncompleteReviewPolicy::Block
8961        ));
8962    }
8963
8964    #[test]
8965    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
8966        // Every seat quota'd, nobody answered: there is no panel to decide
8967        // on, so this must fall through to the existing block-and-retry
8968        // fallback rather than call an unreviewed patch clean.
8969        assert!(!round_is_clean(
8970            0,
8971            true,
8972            0,
8973            2,
8974            2,
8975            IncompleteReviewPolicy::Block
8976        ));
8977    }
8978
8979    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
8980        CommandOutcome {
8981            command: "test".to_owned(),
8982            code,
8983            output_tail: String::new(),
8984            duration_ms: 0,
8985            resource_blocked,
8986        }
8987    }
8988
8989    #[test]
8990    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
8991        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
8992        assert!(
8993            !verify_inconclusive(&[outcome(Some(1), false)]),
8994            "an ordinary failure is still evidence about the patch"
8995        );
8996        assert!(verify_inconclusive(&[outcome(None, true)]));
8997        assert!(
8998            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
8999            "one inconclusive outcome taints the whole batch"
9000        );
9001        assert!(!verify_inconclusive(&[]));
9002    }
9003
9004    #[tokio::test]
9005    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9006        // Alive for the first two checks, then dead - confirms the loop
9007        // actually re-polls rather than deciding once and sleeping out the
9008        // ceiling regardless.
9009        let calls = std::sync::atomic::AtomicUsize::new(0);
9010        let started = Instant::now();
9011        wait_for_pids_with(
9012            &[123],
9013            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9014            Duration::from_millis(5),
9015            Duration::from_secs(5),
9016        )
9017        .await;
9018        assert!(
9019            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9020            "must keep checking rather than deciding on the first answer"
9021        );
9022        assert!(
9023            started.elapsed() < Duration::from_secs(1),
9024            "must return the moment it is confirmed dead, not wait out the ceiling"
9025        );
9026    }
9027
9028    #[tokio::test]
9029    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9030        let started = Instant::now();
9031        wait_for_pids_with(
9032            &[123],
9033            |_| true, // never reports dead
9034            Duration::from_millis(5),
9035            Duration::from_millis(30),
9036        )
9037        .await;
9038        let elapsed = started.elapsed();
9039        assert!(
9040            elapsed >= Duration::from_millis(30),
9041            "must not give up before its own ceiling: {elapsed:?}"
9042        );
9043        assert!(
9044            elapsed < Duration::from_secs(1),
9045            "must not wait past its own ceiling either: {elapsed:?}"
9046        );
9047    }
9048
9049    #[tokio::test]
9050    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9051        let started = Instant::now();
9052        wait_for_pids_with(
9053            &[],
9054            |_| true,
9055            Duration::from_secs(5),
9056            Duration::from_secs(5),
9057        )
9058        .await;
9059        assert!(
9060            started.elapsed() < Duration::from_millis(200),
9061            "an empty pid list has nothing to confirm"
9062        );
9063    }
9064
9065    // `review_conclusion` is the exact decision the review hand-off task
9066    // fixed: a round budget spent (or a tree that stopped moving) must not
9067    // collapse into `Blocked` regardless of what verification actually
9068    // said. Deterministic and process-free for the same reason the
9069    // `round_is_clean` family above is.
9070    fn review_round(
9071        clean: bool,
9072        blocking: usize,
9073        answered: usize,
9074        expected: usize,
9075        progressed: bool,
9076        e2e_ok: bool,
9077    ) -> ReviewRound {
9078        ReviewRound {
9079            round: 1,
9080            head: "h".to_owned(),
9081            verified_head: None,
9082            verified_at: None,
9083            reviews: Vec::new(),
9084            e2e: vec![CommandOutcome {
9085                command: "test".to_owned(),
9086                code: Some(if e2e_ok { 0 } else { 1 }),
9087                output_tail: String::new(),
9088                duration_ms: 0,
9089                resource_blocked: false,
9090            }],
9091            verify_retried: false,
9092            e2e_deferred: false,
9093            e2e_defer_reason: None,
9094            fix: None,
9095            blocking,
9096            answered,
9097            expected,
9098            clean,
9099            progressed,
9100            vote_split: false,
9101            reconsideration: Vec::new(),
9102            verdict: None,
9103        }
9104    }
9105
9106    #[test]
9107    fn review_conclusion_is_none_when_nothing_has_run() {
9108        assert_eq!(review_conclusion(&[], 3), None);
9109    }
9110
9111    #[test]
9112    fn review_conclusion_is_none_while_rounds_remain() {
9113        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9114        assert_eq!(review_conclusion(&rounds, 3), None);
9115    }
9116
9117    #[test]
9118    fn review_conclusion_is_gating_once_a_round_is_clean() {
9119        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9120        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9121    }
9122
9123    #[test]
9124    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9125        let rounds = vec![
9126            review_round(false, 1, 2, 2, true, true),
9127            review_round(false, 1, 2, 2, true, true),
9128        ];
9129        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9130    }
9131
9132    #[test]
9133    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9134        let rounds = vec![
9135            review_round(false, 1, 2, 2, true, true),
9136            review_round(false, 1, 2, 2, true, false),
9137        ];
9138        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9139    }
9140
9141    #[test]
9142    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9143        // Magi never got a command to run against this round's own head — a
9144        // resource-blocked attempt, not a red one — so this must never
9145        // settle on `Blocked` the way a genuine e2e failure would. `None`
9146        // here is what tells `Runner::review_loop` to retry the check
9147        // itself rather than trust this cheap recomputation with a verdict
9148        // it cannot actually produce.
9149        let mut blocked = review_round(false, 1, 2, 2, true, false);
9150        blocked.e2e[0].resource_blocked = true;
9151        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9152        assert_eq!(review_conclusion(&rounds, 2), None);
9153    }
9154
9155    #[test]
9156    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9157        // Missing input, not a verified tree — never a hand-off candidate.
9158        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9159        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9160    }
9161
9162    #[test]
9163    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9164        let rounds = vec![
9165            review_round(false, 1, 2, 2, false, true),
9166            review_round(false, 1, 2, 2, false, true),
9167        ];
9168        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9169    }
9170
9171    fn secs(n: u64) -> Duration {
9172        Duration::from_secs(n)
9173    }
9174
9175    /// A throwaway repo with one commit on `main`, for tests that need `merge`
9176    /// to make real (and, if it runs at all, real*ly fail*) git calls.
9177    fn init_repo(dir: &Path) {
9178        let run = |args: &[&str]| {
9179            let out = std::process::Command::new("git")
9180                .args(args)
9181                .current_dir(dir)
9182                .quiet()
9183                .output()
9184                .expect("spawn git");
9185            assert!(
9186                out.status.success(),
9187                "git {args:?} failed: {}",
9188                String::from_utf8_lossy(&out.stderr)
9189            );
9190        };
9191        run(&["init", "-b", "main"]);
9192        run(&["config", "user.name", "magi test"]);
9193        run(&["config", "user.email", "magi@example.com"]);
9194        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9195        run(&["add", "-A"]);
9196        run(&["commit", "-m", "init"]);
9197    }
9198
9199    // `settle_questions` is what closes the ghost the phone showed: a run's
9200    // seat asked something, the run then ended, and nothing was left to
9201    // abandon the question it left `open`. `HOME` is a process-wide
9202    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
9203    // first time it runs in the binary — every test below still reaches the
9204    // same directory whichever call won, and each gets its own run id from
9205    // `RunState::new`, so they never collide there.
9206    fn ask_test_home() {
9207        crate::run::pin_test_home();
9208    }
9209
9210    /// A minimal, git-free `Runner` at a given status — `settle_questions`
9211    /// reads nothing else off it.
9212    fn runner_at(status: RunStatus) -> Runner {
9213        let mut state = RunState::new(
9214            PathBuf::from("/nonexistent/repo"),
9215            "main".to_owned(),
9216            "deadbeef".to_owned(),
9217            "task".to_owned(),
9218            Config::default(),
9219        );
9220        state.status = status;
9221        Runner {
9222            state,
9223            roles: ResolvedRoles {
9224                implementers: Vec::new(),
9225                judges: Vec::new(),
9226                reviewers: Vec::new(),
9227                fixer: None,
9228                conductor: conductor(),
9229                implementer_roster: Vec::new(),
9230                judge_roster: Vec::new(),
9231                reviewer_roster: Vec::new(),
9232            },
9233            sem: Arc::new(Semaphore::new(1)),
9234            pause: Pause::new(),
9235            interrupt: Pause::new(),
9236        }
9237    }
9238
9239    /// `park_here` folding in the reason `Pause::park_because` recorded -
9240    /// this is what lets an operator reading a run's events tell an
9241    /// interrupt-driven park from an ordinary shutdown park.
9242    #[test]
9243    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9244        crate::run::pin_test_home();
9245        let mut runner = runner_at(RunStatus::Implementing);
9246        let interrupt = Pause::new();
9247        runner.watch_interrupt(interrupt.clone());
9248
9249        interrupt.park_because("task a1b2 asked to run first");
9250
9251        assert!(runner.park_here().expect("park_here"));
9252        assert!(runner.state.parked);
9253        let last = runner.state.events.last().expect("a park event");
9254        assert_eq!(last.node, "park");
9255        assert!(
9256            last.message.contains("task a1b2 asked to run first"),
9257            "expected the interrupt reason in {:?}",
9258            last.message
9259        );
9260    }
9261
9262    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
9263    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
9264    /// cleared) must not make a *different* run - one only watching its own,
9265    /// unshared interrupt `Pause` - see itself as parked. If a future change
9266    /// ever collapsed these back into one handle, the interrupt scheduler
9267    /// would park every run for the rest of the daemon's life, not just the
9268    /// one it meant to interrupt.
9269    #[test]
9270    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9271        crate::run::pin_test_home();
9272        let mut runner = runner_at(RunStatus::Implementing);
9273        let shutdown = Pause::new();
9274        runner.on_pause(shutdown.clone());
9275        let interrupt = Pause::new();
9276        runner.watch_interrupt(interrupt.clone());
9277
9278        // Nobody has asked for anything yet.
9279        assert!(!runner.park_here().expect("park_here"));
9280        assert!(!runner.state.parked);
9281
9282        // Only the interrupt handle fires; the shutdown handle stays clear.
9283        interrupt.park_because("test");
9284        assert!(!shutdown.parked());
9285        assert!(runner.park_here().expect("park_here"));
9286    }
9287
9288    /// The property every prior attempt at this feature failed to pin down:
9289    /// asking a run to park while one of its nodes has a real, in-flight
9290    /// async operation running (an agent call, in production) must not cut
9291    /// that operation short. `park_here` is only ever consulted *between*
9292    /// `execute`'s node calls - see its own doc - so nothing inside a node
9293    /// can observe a park request until the node itself returns. This proves
9294    /// that structurally, with real `tokio` concurrency and a channel
9295    /// handshake (never a sleep, which would only prove "usually", not
9296    /// "cannot"): the "node" below reports that it has genuinely started,
9297    /// and only then is the park requested; the node still has to be told to
9298    /// finish before `park_here` is ever called, exactly mirroring every
9299    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
9300    /// in `execute`.
9301    #[tokio::test]
9302    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9303        crate::run::pin_test_home();
9304        let mut runner = runner_at(RunStatus::Implementing);
9305        let interrupt = Pause::new();
9306        runner.watch_interrupt(interrupt.clone());
9307
9308        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9309        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9310
9311        // Stands in for one node's in-flight agent call: it proves it has
9312        // genuinely started, then blocks - exactly as a spawned CLI process
9313        // does - until told to finish.
9314        let node = async move {
9315            started_tx.send(()).expect("send started");
9316            finish_rx.await.expect("recv finish");
9317            "node finished"
9318        };
9319
9320        let interrupter = async move {
9321            started_rx.await.expect("recv started");
9322            // The call is now genuinely in flight. Ask it to park.
9323            interrupt.park_because("higher-priority task waiting");
9324            // Nothing the node does can observe this yet - there is no
9325            // check inside it, by construction - so let the executor run
9326            // anything pending and then let the node finish on its own.
9327            tokio::task::yield_now().await;
9328            finish_tx.send(()).expect("send finish");
9329        };
9330
9331        let (node_result, ()) = tokio::join!(node, interrupter);
9332        assert_eq!(
9333            node_result, "node finished",
9334            "the in-flight call ran to completion"
9335        );
9336
9337        // Only now, at the boundary the real `execute` would check right
9338        // after this node, does the park take effect.
9339        assert!(runner.park_here().expect("park_here"));
9340        assert!(runner.state.parked);
9341    }
9342
9343    /// A run parked mid-competition carries every field it had accumulated
9344    /// through the exact same disk round-trip an ordinary resume uses -
9345    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
9346    /// Nothing about parking for an interrupt is a special case of that path;
9347    /// this is what proves it rather than assuming it.
9348    #[test]
9349    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9350        crate::run::pin_test_home();
9351        let mut runner = runner_at(RunStatus::Judging);
9352        // `Runner::resume` re-resolves roles from the saved config, which
9353        // refuses an empty roster - give it the same minimal one `conductor`
9354        // itself uses.
9355        runner.state.config.agents = vec![conductor()];
9356        runner.state.candidates = vec![Candidate {
9357            index: 0,
9358            label: 'A',
9359            agent: "alpha".to_owned(),
9360            branch: "magi/x/A".to_owned(),
9361            worktree: PathBuf::from("/nonexistent/worktree"),
9362            summary: "did the thing".to_owned(),
9363            stat: "1 file changed".to_owned(),
9364            files: 1,
9365            commits: 1,
9366            empty: false,
9367            failed: None,
9368            verified_noop: None,
9369            duration_ms: 1234,
9370            folded: false,
9371        }];
9372        let run_id = runner.state.id.clone();
9373
9374        let interrupt = Pause::new();
9375        runner.watch_interrupt(interrupt.clone());
9376        interrupt.park_because("task c3d4 asked to run first");
9377        assert!(runner.park_here().expect("park_here"));
9378
9379        let resumed = Runner::resume(&run_id).expect("resume");
9380        assert_eq!(resumed.state.candidates.len(), 1);
9381        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9382        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9383        assert_eq!(resumed.state.status, runner.state.status);
9384        assert!(
9385            resumed.state.parked,
9386            "still parked until `execute` actually walks the graph again"
9387        );
9388        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9389    }
9390
9391    /// A fresh open question on `run`, stored and handed back for assertions.
9392    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9393        let mut q = ask::Question::new(
9394            run.to_owned(),
9395            "implement".to_owned(),
9396            "impl-A".to_owned(),
9397            "Which storage backend should the cache use?".to_owned(),
9398            String::new(),
9399            vec!["SQLite".to_owned(), "Redis".to_owned()],
9400        );
9401        store.put(&mut q).unwrap();
9402        q
9403    }
9404
9405    #[test]
9406    fn a_failed_runs_open_question_is_abandoned() {
9407        ask_test_home();
9408        let store = ask::Questions::open();
9409        let mut runner = runner_at(RunStatus::Failed);
9410        let run = runner.state.id.clone();
9411        let q = ask_open_question(&store, &run);
9412
9413        runner.settle_questions();
9414
9415        let back = store.get(&q.id).unwrap();
9416        assert!(
9417            !back.status.open(),
9418            "the seat that asked died with the run; nobody is left to read an answer"
9419        );
9420        assert!(
9421            back.detail.contains(&run) && back.detail.contains("failed"),
9422            "the reason names what the run became, not just that it is gone: {}",
9423            back.detail
9424        );
9425    }
9426
9427    #[test]
9428    fn a_merged_runs_open_question_is_abandoned_too() {
9429        ask_test_home();
9430        let store = ask::Questions::open();
9431        // A run that finishes cleanly still leaves nobody to read an answer -
9432        // this is not only a failure-path cleanup.
9433        for status in [RunStatus::Merged, RunStatus::Ready] {
9434            let mut runner = runner_at(status);
9435            let run = runner.state.id.clone();
9436            let q = ask_open_question(&store, &run);
9437
9438            runner.settle_questions();
9439
9440            let back = store.get(&q.id).unwrap();
9441            assert!(
9442                !back.status.open(),
9443                "{status:?} run's question must not outlive the run"
9444            );
9445        }
9446    }
9447
9448    #[test]
9449    fn a_still_resumable_runs_open_question_is_left_alone() {
9450        ask_test_home();
9451        let store = ask::Questions::open();
9452        // `Blocked` and `Stalled` can still be resumed — the candidates, the
9453        // review round and the seat sessions are all still on disk — so a
9454        // question asked mid-round may yet get a real answer from a real
9455        // resume. Sweeping it here would be exactly the failure mode this
9456        // whole feature exists to avoid on the other side.
9457        for status in [RunStatus::Blocked, RunStatus::Stalled] {
9458            let mut runner = runner_at(status);
9459            let run = runner.state.id.clone();
9460            let q = ask_open_question(&store, &run);
9461
9462            runner.settle_questions();
9463
9464            let back = store.get(&q.id).unwrap();
9465            assert!(
9466                back.status.open(),
9467                "{status:?} is still alive; the question must still be waiting"
9468            );
9469        }
9470    }
9471
9472    #[test]
9473    fn settle_questions_never_touches_an_already_answered_question() {
9474        ask_test_home();
9475        let store = ask::Questions::open();
9476        let mut runner = runner_at(RunStatus::Failed);
9477        let run = runner.state.id.clone();
9478        let mut q = ask_open_question(&store, &run);
9479        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
9480            .unwrap();
9481        store.put(&mut q).unwrap();
9482
9483        // Called twice, the way a crash-recovered daemon reclaim and the
9484        // graph's own cleanup both can for the same run — `abandon_for_run`
9485        // only ever touches what is still open, so this must be inert both
9486        // times, not merely the second.
9487        runner.settle_questions();
9488        runner.settle_questions();
9489
9490        let back = store.get(&q.id).unwrap();
9491        assert_eq!(
9492            back.status,
9493            ask::QuestionStatus::Answered,
9494            "a real answer is a decision on record, never overwritten by a sweep"
9495        );
9496    }
9497
9498    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
9499    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
9500    /// without merging, whose winner is still the operator's answer to read.
9501    /// Nothing previously called `fold_run` itself with a real `tally`, so
9502    /// this is the first test to pin down the one distinction the whole
9503    /// automatic-fold feature depends on: the winner's worktree and branch
9504    /// must survive, everything else sharing the run's worktree bay - a
9505    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
9506    /// second sweep treats every non-winner directory under the bay alike -
9507    /// must not.
9508    #[tokio::test]
9509    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
9510        crate::run::pin_test_home();
9511        let tmp = tempfile::tempdir().expect("tempdir");
9512        let repo = tmp.path().join("repo");
9513        std::fs::create_dir_all(&repo).unwrap();
9514        init_repo(&repo);
9515
9516        let mut config = Config::default();
9517        config.graph.worktree_root = Some(tmp.path().join("wt"));
9518
9519        let mut state = RunState::new(
9520            repo.clone(),
9521            "main".to_owned(),
9522            "deadbeef".to_owned(),
9523            "task".to_owned(),
9524            config,
9525        );
9526        let root = state.worktree_root();
9527        let wt_a = root.join("cand-A");
9528        let wt_b = root.join("cand-B");
9529        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
9530            .await
9531            .expect("worktree A");
9532        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
9533            .await
9534            .expect("worktree B");
9535
9536        state.candidates = vec![
9537            Candidate {
9538                index: 0,
9539                label: 'A',
9540                agent: "alpha".to_owned(),
9541                branch: "magi/x/A".to_owned(),
9542                worktree: wt_a.clone(),
9543                summary: String::new(),
9544                stat: String::new(),
9545                files: 0,
9546                commits: 0,
9547                empty: false,
9548                failed: None,
9549                verified_noop: None,
9550                duration_ms: 0,
9551                folded: false,
9552            },
9553            Candidate {
9554                index: 1,
9555                label: 'B',
9556                agent: "beta".to_owned(),
9557                branch: "magi/x/B".to_owned(),
9558                worktree: wt_b.clone(),
9559                summary: String::new(),
9560                stat: String::new(),
9561                files: 0,
9562                commits: 0,
9563                empty: false,
9564                failed: None,
9565                verified_noop: None,
9566                duration_ms: 0,
9567                folded: false,
9568            },
9569        ];
9570        state.tally = Some(Tally {
9571            first_choice: BTreeMap::from([('A', 1)]),
9572            borda: BTreeMap::new(),
9573            winner: 'A',
9574            rankings: 1,
9575            unanimous_initial: true,
9576            deliberated: false,
9577            changed_votes: 0,
9578            unanimous_final: true,
9579            tie_break: None,
9580            judges: 1,
9581            present: 1,
9582            quorum: 1,
9583            met_quorum: true,
9584            uncontested: None,
9585        });
9586        state.status = RunStatus::Ready;
9587
9588        fold_run(&mut state, false, &crate::run::home())
9589            .await
9590            .expect("fold_run");
9591
9592        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
9593        assert!(
9594            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9595            "the unmerged winner's branch survives"
9596        );
9597        assert!(
9598            !state.candidates[0].folded,
9599            "the winner is not marked folded"
9600        );
9601
9602        assert!(!wt_b.exists(), "the loser's worktree is removed");
9603        assert!(
9604            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
9605            "the loser's branch is removed"
9606        );
9607        assert!(state.candidates[1].folded, "the loser is marked folded");
9608    }
9609
9610    /// A branch handed to a later run is that run's (and its pull request's):
9611    /// folding the run that released it must not delete it.
9612    #[tokio::test]
9613    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
9614        let tmp = tempfile::tempdir().expect("tempdir");
9615        let repo = tmp.path().join("repo");
9616        std::fs::create_dir_all(&repo).unwrap();
9617        init_repo(&repo);
9618        let home = tmp.path().join("home");
9619
9620        let mut config = Config::default();
9621        config.graph.worktree_root = Some(tmp.path().join("wt"));
9622        let mut state = RunState::new(
9623            repo.clone(),
9624            "main".to_owned(),
9625            "deadbeef".to_owned(),
9626            "task".to_owned(),
9627            config,
9628        );
9629        // The worktree is already gone (released); the branch survives.
9630        git::git(&repo, &["branch", "magi/x/A", "main"])
9631            .await
9632            .expect("branch");
9633        state.candidates = vec![Candidate {
9634            index: 0,
9635            label: 'A',
9636            agent: "alpha".to_owned(),
9637            branch: "magi/x/A".to_owned(),
9638            worktree: state.worktree_root().join("cand-A"),
9639            summary: String::new(),
9640            stat: String::new(),
9641            files: 0,
9642            commits: 0,
9643            empty: false,
9644            failed: None,
9645            verified_noop: None,
9646            duration_ms: 0,
9647            folded: true,
9648        }];
9649        state.released_to = Some("20260901-000000-new1".to_owned());
9650        state.released_branches = vec!["magi/x/A".to_owned()];
9651
9652        fold_run(&mut state, true, &home).await.expect("fold_run");
9653
9654        assert!(
9655            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
9656            "the handed-over branch survives a fold"
9657        );
9658    }
9659
9660    /// A winner with nothing ahead of the base is caught before `gh` is ever
9661    /// asked for a pull request, and the message carries what the task's
9662    /// references resolved to.
9663    #[tokio::test]
9664    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
9665        let tmp = tempfile::tempdir().expect("tempdir");
9666        let repo = tmp.path().join("repo");
9667        std::fs::create_dir_all(&repo).unwrap();
9668        init_repo(&repo);
9669        let run = |args: &[&str]| {
9670            let out = std::process::Command::new("git")
9671                .quiet()
9672                .args(args)
9673                .current_dir(&repo)
9674                .output()
9675                .expect("spawn git");
9676            assert!(out.status.success(), "git {args:?}");
9677        };
9678        run(&["branch", "magi/x/A"]);
9679        run(&["checkout", "-q", "-b", "magi/x/B"]);
9680        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
9681        run(&["add", "-A"]);
9682        run(&["commit", "-q", "-m", "work"]);
9683        run(&["checkout", "-q", "main"]);
9684
9685        // A pull request is compared against the remote's base, so the
9686        // fixture needs one. Before it exists nothing can be read, and the
9687        // answer must be "not empty".
9688        let probe = RunState::new(
9689            repo.clone(),
9690            "main".to_owned(),
9691            "deadbeef".to_owned(),
9692            "task".to_owned(),
9693            Config::default(),
9694        );
9695        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
9696        let bare = tmp.path().join("origin.git");
9697        let out = std::process::Command::new("git")
9698            .quiet()
9699            .args(["init", "-q", "--bare"])
9700            .arg(&bare)
9701            .output()
9702            .expect("spawn git");
9703        assert!(out.status.success(), "git init --bare");
9704        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
9705        run(&["push", "-q", "origin", "main"]);
9706
9707        let mut state = RunState::new(
9708            repo.clone(),
9709            "main".to_owned(),
9710            "deadbeef".to_owned(),
9711            "task".to_owned(),
9712            Config::default(),
9713        );
9714        state.seeds = vec![refs::Seed {
9715            token: "magi/27b2/A".to_owned(),
9716            kind: refs::SeedKind::Unresolved,
9717            sha: String::new(),
9718            branch: true,
9719            detail: "no branch or commit named magi/27b2/A".to_owned(),
9720        }];
9721
9722        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
9723        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
9724        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
9725        let detail = empty_candidate_detail(&state, "main");
9726        assert!(detail.starts_with("empty candidate"), "{detail}");
9727        assert!(detail.contains("magi/27b2/A"), "{detail}");
9728    }
9729
9730    /// `status == Ready` used to be read as "this is the harmless
9731    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
9732    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
9733    /// run's PR was closed without merging — and reentering `merge` with
9734    /// `mode` still `Pr` does not know the difference, so it pushed and
9735    /// opened a second pull request. `mode == Local` reproduces the same
9736    /// blind spot without a network call: reentry must not attempt another
9737    /// git merge once this node has already recorded an outcome.
9738    #[tokio::test]
9739    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
9740        let tmp = tempfile::tempdir().expect("tempdir");
9741        let repo = tmp.path().join("repo");
9742        std::fs::create_dir_all(&repo).unwrap();
9743        init_repo(&repo);
9744
9745        let mut config = Config::default();
9746        config.merge.mode = MergeMode::Local;
9747
9748        let mut state = RunState::new(
9749            repo.clone(),
9750            "main".to_owned(),
9751            "deadbeef".to_owned(),
9752            "task".to_owned(),
9753            config,
9754        );
9755        state.candidates = vec![Candidate {
9756            index: 0,
9757            label: 'A',
9758            agent: "alpha".to_owned(),
9759            branch: "does-not-exist".to_owned(),
9760            worktree: repo.clone(),
9761            summary: String::new(),
9762            stat: String::new(),
9763            files: 0,
9764            commits: 0,
9765            empty: false,
9766            failed: None,
9767            verified_noop: None,
9768            duration_ms: 0,
9769            folded: false,
9770        }];
9771        state.tally = Some(Tally {
9772            first_choice: BTreeMap::from([('A', 1)]),
9773            borda: BTreeMap::new(),
9774            winner: 'A',
9775            rankings: 1,
9776            unanimous_initial: true,
9777            deliberated: false,
9778            changed_votes: 0,
9779            unanimous_final: true,
9780            tie_break: None,
9781            judges: 0,
9782            present: 0,
9783            quorum: 0,
9784            met_quorum: true,
9785            uncontested: Some("only candidate A produced a change".to_owned()),
9786        });
9787        state.reviews = vec![ReviewRound {
9788            round: 1,
9789            head: "deadbeef".to_owned(),
9790            verified_head: None,
9791            verified_at: None,
9792            reviews: Vec::new(),
9793            e2e: Vec::new(),
9794            fix: None,
9795            blocking: 0,
9796            answered: 0,
9797            expected: 0,
9798            clean: true,
9799            verify_retried: false,
9800            e2e_deferred: false,
9801            e2e_defer_reason: None,
9802            progressed: false,
9803            vote_split: false,
9804            reconsideration: Vec::new(),
9805            verdict: None,
9806        }];
9807        state.gate = vec![CommandOutcome {
9808            command: "test".to_owned(),
9809            code: Some(0),
9810            output_tail: String::new(),
9811            duration_ms: 0,
9812            resource_blocked: false,
9813        }];
9814        state.gate_ran = true;
9815        // Reached its conclusion already — e.g. `land` closing the PR without
9816        // merging it, which (like the honest `MergeMode::None` path) leaves
9817        // `status` at `Ready`. The recorded outcome is what actually marks
9818        // this node done.
9819        state.status = RunStatus::Ready;
9820        state.merge = Some(MergeOutcome {
9821            mode: MergeMode::Local,
9822            ok: false,
9823            detail: "already concluded".to_owned(),
9824            empty: false,
9825        });
9826
9827        let mut runner = Runner {
9828            state,
9829            roles: ResolvedRoles {
9830                implementers: Vec::new(),
9831                judges: Vec::new(),
9832                reviewers: Vec::new(),
9833                fixer: None,
9834                conductor: conductor(),
9835                implementer_roster: Vec::new(),
9836                judge_roster: Vec::new(),
9837                reviewer_roster: Vec::new(),
9838            },
9839            sem: Arc::new(Semaphore::new(1)),
9840            pause: Pause::new(),
9841            interrupt: Pause::new(),
9842        };
9843
9844        runner.merge().await.expect("merge");
9845
9846        assert_eq!(
9847            runner.state.status,
9848            RunStatus::Ready,
9849            "a concluded run's status must not change on reentry"
9850        );
9851        assert_eq!(
9852            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
9853            Some("already concluded"),
9854            "merge must not run again once the node already recorded an outcome"
9855        );
9856    }
9857
9858    /// `gate` leaves `state.gate_ran` false both before it has ever run and
9859    /// when its last attempt was resource-blocked (the shared build cache
9860    /// could not be acquired or confirmed fresh in time - see
9861    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
9862    /// `Vec` this also leaves behind used to read as "nothing failed" and let
9863    /// a run merge a tree the gate never actually checked - exactly the case
9864    /// a contended cache produces on every retry until it clears. `merge`
9865    /// must refuse until `gate` has actually recorded an attempt.
9866    #[tokio::test]
9867    async fn merge_refuses_a_gate_that_has_not_actually_run() {
9868        let tmp = tempfile::tempdir().expect("tempdir");
9869        let repo = tmp.path().join("repo");
9870        std::fs::create_dir_all(&repo).unwrap();
9871        init_repo(&repo);
9872
9873        let mut config = Config::default();
9874        config.merge.mode = MergeMode::Local;
9875
9876        let mut state = RunState::new(
9877            repo.clone(),
9878            "main".to_owned(),
9879            "deadbeef".to_owned(),
9880            "task".to_owned(),
9881            config,
9882        );
9883        state.candidates = vec![Candidate {
9884            index: 0,
9885            label: 'A',
9886            agent: "alpha".to_owned(),
9887            branch: "does-not-exist".to_owned(),
9888            worktree: repo.clone(),
9889            summary: String::new(),
9890            stat: String::new(),
9891            files: 0,
9892            commits: 0,
9893            empty: false,
9894            failed: None,
9895            verified_noop: None,
9896            duration_ms: 0,
9897            folded: false,
9898        }];
9899        state.tally = Some(Tally {
9900            first_choice: BTreeMap::from([('A', 1)]),
9901            borda: BTreeMap::new(),
9902            winner: 'A',
9903            rankings: 1,
9904            unanimous_initial: true,
9905            deliberated: false,
9906            changed_votes: 0,
9907            unanimous_final: true,
9908            tie_break: None,
9909            judges: 0,
9910            present: 0,
9911            quorum: 0,
9912            met_quorum: true,
9913            uncontested: Some("only candidate A produced a change".to_owned()),
9914        });
9915        state.reviews = vec![ReviewRound {
9916            round: 1,
9917            head: "deadbeef".to_owned(),
9918            verified_head: None,
9919            verified_at: None,
9920            reviews: Vec::new(),
9921            e2e: Vec::new(),
9922            fix: None,
9923            blocking: 0,
9924            answered: 0,
9925            expected: 0,
9926            clean: true,
9927            verify_retried: false,
9928            e2e_deferred: false,
9929            e2e_defer_reason: None,
9930            progressed: false,
9931            vote_split: false,
9932            reconsideration: Vec::new(),
9933            verdict: None,
9934        }];
9935        // The point: `gate` has not recorded anything yet.
9936        state.gate = Vec::new();
9937        state.gate_ran = false;
9938        state.status = RunStatus::Gating;
9939
9940        let mut runner = Runner {
9941            state,
9942            roles: ResolvedRoles {
9943                implementers: Vec::new(),
9944                judges: Vec::new(),
9945                reviewers: Vec::new(),
9946                fixer: None,
9947                conductor: conductor(),
9948                implementer_roster: Vec::new(),
9949                judge_roster: Vec::new(),
9950                reviewer_roster: Vec::new(),
9951            },
9952            sem: Arc::new(Semaphore::new(1)),
9953            pause: Pause::new(),
9954            interrupt: Pause::new(),
9955        };
9956
9957        runner.merge().await.expect("merge");
9958
9959        assert!(
9960            runner.state.merge.is_none(),
9961            "an empty gate must never be read as a passing one: {:?}",
9962            runner.state.merge
9963        );
9964    }
9965
9966    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
9967    /// commands configured and `merge.mode` is `none` (a review-only run).
9968    /// `gate` must still record a real attempt — zero commands, vacuously
9969    /// passed — rather than leaving `state.gate` empty in a way `merge`
9970    /// cannot tell apart from "never ran"; otherwise the run reaches
9971    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
9972    #[tokio::test]
9973    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
9974        let tmp = tempfile::tempdir().expect("tempdir");
9975        let repo = tmp.path().join("repo");
9976        std::fs::create_dir_all(&repo).unwrap();
9977        init_repo(&repo);
9978
9979        // Default config: `verify.gate` empty, `merge.mode` is `none`.
9980        let config = Config::default();
9981
9982        let mut state = RunState::new(
9983            repo.clone(),
9984            "main".to_owned(),
9985            "deadbeef".to_owned(),
9986            "task".to_owned(),
9987            config,
9988        );
9989        state.candidates = vec![Candidate {
9990            index: 0,
9991            label: 'A',
9992            agent: "alpha".to_owned(),
9993            branch: "does-not-exist".to_owned(),
9994            worktree: repo.clone(),
9995            summary: String::new(),
9996            stat: String::new(),
9997            files: 0,
9998            commits: 0,
9999            empty: false,
10000            failed: None,
10001            verified_noop: None,
10002            duration_ms: 0,
10003            folded: false,
10004        }];
10005        state.tally = Some(Tally {
10006            first_choice: BTreeMap::from([('A', 1)]),
10007            borda: BTreeMap::new(),
10008            winner: 'A',
10009            rankings: 1,
10010            unanimous_initial: true,
10011            deliberated: false,
10012            changed_votes: 0,
10013            unanimous_final: true,
10014            tie_break: None,
10015            judges: 0,
10016            present: 0,
10017            quorum: 0,
10018            met_quorum: true,
10019            uncontested: Some("only candidate A produced a change".to_owned()),
10020        });
10021        state.reviews = vec![ReviewRound {
10022            round: 1,
10023            head: "deadbeef".to_owned(),
10024            verified_head: None,
10025            verified_at: None,
10026            reviews: Vec::new(),
10027            e2e: Vec::new(),
10028            fix: None,
10029            blocking: 0,
10030            answered: 0,
10031            expected: 0,
10032            clean: true,
10033            verify_retried: false,
10034            e2e_deferred: false,
10035            e2e_defer_reason: None,
10036            progressed: false,
10037            vote_split: false,
10038            reconsideration: Vec::new(),
10039            verdict: None,
10040        }];
10041
10042        let mut runner = Runner {
10043            state,
10044            roles: ResolvedRoles {
10045                implementers: Vec::new(),
10046                judges: Vec::new(),
10047                reviewers: Vec::new(),
10048                fixer: None,
10049                conductor: conductor(),
10050                implementer_roster: Vec::new(),
10051                judge_roster: Vec::new(),
10052                reviewer_roster: Vec::new(),
10053            },
10054            sem: Arc::new(Semaphore::new(1)),
10055            pause: Pause::new(),
10056            interrupt: Pause::new(),
10057        };
10058
10059        runner.gate().await.expect("gate");
10060        assert!(
10061            runner.state.gate_ran,
10062            "zero configured commands is still a real attempt, not an unrun gate"
10063        );
10064        assert!(runner.state.gate.is_empty());
10065        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10066        assert_ne!(
10067            runner.state.status,
10068            RunStatus::Blocked,
10069            "a gate with nothing to check must not read as failed"
10070        );
10071
10072        runner.merge().await.expect("merge");
10073        assert_eq!(
10074            runner.state.status,
10075            RunStatus::Ready,
10076            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10077        );
10078    }
10079
10080    /// `Config::cache_dir` is derived from `verify.e2e` as well as
10081    /// `verify.gate` (so the e2e leg and the final gate never build against
10082    /// different directories). With zero `verify.gate` commands but a
10083    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
10084    /// that lease before discovering it had nothing to run - so a repo with
10085    /// no gate commands could come back `resource_blocked` (and therefore
10086    /// still `gate_ran == false`) on nothing but an unrelated run holding the
10087    /// cache, exactly the contention this run's own zero commands could
10088    /// never have touched. `gate` must recognise there is nothing to check
10089    /// before it ever asks for the lease.
10090    #[tokio::test]
10091    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10092        crate::run::pin_test_home();
10093        let home = crate::run::home();
10094
10095        let tmp = tempfile::tempdir().expect("tempdir");
10096        let repo = tmp.path().join("repo");
10097        std::fs::create_dir_all(&repo).unwrap();
10098        init_repo(&repo);
10099        // Unique to this test, so holding its lease cannot collide with
10100        // another test sharing the same process-wide `home`.
10101        let cache_dir = tmp.path().join("target");
10102
10103        let mut config = Config::default();
10104        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10105        // `verify.gate` stays empty (the default). Bounded so a regression
10106        // that does start waiting fails the test in seconds, not hangs it.
10107        config.graph.timeout_verify = Some(2);
10108
10109        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10110        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10111            .expect("no io error acquiring directly")
10112        {
10113            crate::cache::AcquireOutcome::Acquired(g) => g,
10114            crate::cache::AcquireOutcome::Busy(b) => {
10115                panic!("expected the direct acquire to win the lease first: {b:?}")
10116            }
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        state.candidates = vec![Candidate {
10127            index: 0,
10128            label: 'A',
10129            agent: "alpha".to_owned(),
10130            branch: "does-not-exist".to_owned(),
10131            worktree: repo.clone(),
10132            summary: String::new(),
10133            stat: String::new(),
10134            files: 0,
10135            commits: 0,
10136            empty: false,
10137            failed: None,
10138            verified_noop: None,
10139            duration_ms: 0,
10140            folded: false,
10141        }];
10142        state.tally = Some(Tally {
10143            first_choice: BTreeMap::from([('A', 1)]),
10144            borda: BTreeMap::new(),
10145            winner: 'A',
10146            rankings: 1,
10147            unanimous_initial: true,
10148            deliberated: false,
10149            changed_votes: 0,
10150            unanimous_final: true,
10151            tie_break: None,
10152            judges: 0,
10153            present: 0,
10154            quorum: 0,
10155            met_quorum: true,
10156            uncontested: Some("only candidate A produced a change".to_owned()),
10157        });
10158        state.reviews = vec![ReviewRound {
10159            round: 1,
10160            head: "deadbeef".to_owned(),
10161            verified_head: None,
10162            verified_at: None,
10163            reviews: Vec::new(),
10164            e2e: Vec::new(),
10165            fix: None,
10166            blocking: 0,
10167            answered: 0,
10168            expected: 0,
10169            clean: true,
10170            verify_retried: false,
10171            e2e_deferred: false,
10172            e2e_defer_reason: None,
10173            progressed: false,
10174            vote_split: false,
10175            reconsideration: Vec::new(),
10176            verdict: None,
10177        }];
10178
10179        let mut runner = Runner {
10180            state,
10181            roles: ResolvedRoles {
10182                implementers: Vec::new(),
10183                judges: Vec::new(),
10184                reviewers: Vec::new(),
10185                fixer: None,
10186                conductor: conductor(),
10187                implementer_roster: Vec::new(),
10188                judge_roster: Vec::new(),
10189                reviewer_roster: Vec::new(),
10190            },
10191            sem: Arc::new(Semaphore::new(1)),
10192            pause: Pause::new(),
10193            interrupt: Pause::new(),
10194        };
10195
10196        let started = std::time::Instant::now();
10197        runner.gate().await.expect("gate");
10198        assert!(
10199            started.elapsed() < Duration::from_secs(1),
10200            "a gate with nothing to run must never wait on a lease it never needed"
10201        );
10202        assert!(
10203            runner.state.gate_ran,
10204            "zero commands is still a real, immediate attempt"
10205        );
10206        assert!(runner.state.gate.is_empty());
10207        assert_ne!(
10208            runner.state.status,
10209            RunStatus::Blocked,
10210            "must not read as resource-blocked on a lease it never asked for"
10211        );
10212    }
10213
10214    /// The addendum's second gap: a `verify.gate` command running for real
10215    /// wall-clock time had nothing at all to show for it in `active` before
10216    /// `run_commands` learned to record it — a run could sit in `Gating` for
10217    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
10218    /// what was actually happening. Proven with a genuinely still-running
10219    /// command, not just a before/after check on the final state: a poller
10220    /// task reads the same `run.json` `gate()` is writing, the same way the
10221    /// phone or `magi show` would, while the shell command is still blocked
10222    /// on its own release marker.
10223    #[tokio::test]
10224    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10225        crate::run::pin_test_home();
10226
10227        let tmp = tempfile::tempdir().expect("tempdir");
10228        let repo = tmp.path().join("repo");
10229        std::fs::create_dir_all(&repo).unwrap();
10230        init_repo(&repo);
10231
10232        let mut config = Config::default();
10233        config.verify.gate = vec![
10234            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10235             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10236                .to_owned(),
10237        ];
10238
10239        let mut state = RunState::new(
10240            repo.clone(),
10241            "main".to_owned(),
10242            "deadbeef".to_owned(),
10243            "task".to_owned(),
10244            config,
10245        );
10246        let run_id = state.id.clone();
10247        state.candidates = vec![Candidate {
10248            index: 0,
10249            label: 'A',
10250            agent: "alpha".to_owned(),
10251            branch: "does-not-exist".to_owned(),
10252            worktree: repo.clone(),
10253            summary: String::new(),
10254            stat: String::new(),
10255            files: 0,
10256            commits: 0,
10257            empty: false,
10258            failed: None,
10259            verified_noop: None,
10260            duration_ms: 0,
10261            folded: false,
10262        }];
10263        state.tally = Some(Tally {
10264            first_choice: BTreeMap::from([('A', 1)]),
10265            borda: BTreeMap::new(),
10266            winner: 'A',
10267            rankings: 1,
10268            unanimous_initial: true,
10269            deliberated: false,
10270            changed_votes: 0,
10271            unanimous_final: true,
10272            tie_break: None,
10273            judges: 0,
10274            present: 0,
10275            quorum: 0,
10276            met_quorum: true,
10277            uncontested: Some("only candidate A produced a change".to_owned()),
10278        });
10279        state.reviews = vec![ReviewRound {
10280            round: 1,
10281            head: "deadbeef".to_owned(),
10282            verified_head: None,
10283            verified_at: None,
10284            reviews: Vec::new(),
10285            e2e: Vec::new(),
10286            fix: None,
10287            blocking: 0,
10288            answered: 0,
10289            expected: 0,
10290            clean: true,
10291            verify_retried: false,
10292            e2e_deferred: false,
10293            e2e_defer_reason: None,
10294            progressed: false,
10295            vote_split: false,
10296            reconsideration: Vec::new(),
10297            verdict: None,
10298        }];
10299
10300        let mut runner = Runner {
10301            state,
10302            roles: ResolvedRoles {
10303                implementers: Vec::new(),
10304                judges: Vec::new(),
10305                reviewers: Vec::new(),
10306                fixer: None,
10307                conductor: conductor(),
10308                implementer_roster: Vec::new(),
10309                judge_roster: Vec::new(),
10310                reviewer_roster: Vec::new(),
10311            },
10312            sem: Arc::new(Semaphore::new(1)),
10313            pause: Pause::new(),
10314            interrupt: Pause::new(),
10315        };
10316
10317        let started_marker = repo.join("started.marker");
10318        let release_marker = repo.join("release.marker");
10319        let poller = tokio::spawn(async move {
10320            // Bounded so a regression that never records the task entry
10321            // fails this test in seconds instead of hanging the suite —
10322            // the same shape `a_park_requested_while_a_seat_is_mid_call_
10323            // does_not_cut_it_short` uses for the same reason.
10324            for _ in 0..100 {
10325                if started_marker.exists()
10326                    && let Ok(s) = crate::run::RunState::load(&run_id)
10327                    && let Some(a) = s.active.get("gate")
10328                {
10329                    std::fs::write(&release_marker, b"go").expect("release marker");
10330                    return Some(a.clone());
10331                }
10332                tokio::time::sleep(Duration::from_millis(50)).await;
10333            }
10334            None
10335        });
10336
10337        runner.gate().await.expect("gate");
10338        let captured = poller.await.expect("poller task");
10339        let captured = captured.expect(
10340            "the poller never saw a `gate` task entry in run.json while the command was \
10341             still blocked on its own release marker",
10342        );
10343
10344        assert_eq!(captured.task.as_deref(), Some("gate"));
10345        assert_eq!(captured.node, "gate");
10346        assert_eq!(captured.index, Some(1));
10347        assert_eq!(captured.total, Some(1));
10348        assert!(
10349            captured
10350                .command
10351                .as_deref()
10352                .is_some_and(|c| c.contains("started.marker")),
10353            "{captured:?}"
10354        );
10355
10356        assert!(
10357            runner.state.active.is_empty(),
10358            "the entry must be cleared once the command actually finished: {:?}",
10359            runner.state.active
10360        );
10361        assert!(runner.state.gate_ran);
10362        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10363    }
10364
10365    /// The hand-off over a blocking finding a reviewer rejected on leaves a
10366    /// record for `land`; one with only a Minor, or no reject, leaves none.
10367    #[tokio::test]
10368    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10369        use crate::verdict::{Finding, ReviewVote, Severity};
10370        crate::run::pin_test_home();
10371        let tmp = tempfile::tempdir().expect("tempdir");
10372        let repo = tmp.path().join("repo");
10373        std::fs::create_dir_all(&repo).unwrap();
10374        init_repo(&repo);
10375
10376        for (severity, vote, expect) in [
10377            (Severity::Major, ReviewVote::Reject, true),
10378            (Severity::Minor, ReviewVote::Reject, false),
10379            (Severity::Major, ReviewVote::Approve, false),
10380        ] {
10381            let mut round = review_round(false, 1, 1, 1, false, true);
10382            round.reviews = vec![ReviewRecord {
10383                reviewer: 1,
10384                agent: "alpha".to_owned(),
10385                summary: String::new(),
10386                findings: vec![Finding {
10387                    id: "R1-1-1".to_owned(),
10388                    severity,
10389                    file: None,
10390                    line: None,
10391                    title: "t".to_owned(),
10392                    detail: String::new(),
10393                }],
10394                vote: Some(vote),
10395                failed: None,
10396                duration_ms: 0,
10397                attempts: 0,
10398            }];
10399            let mut state = RunState::new(
10400                repo.clone(),
10401                "main".to_owned(),
10402                "deadbeef".to_owned(),
10403                "task".to_owned(),
10404                Config::default(),
10405            );
10406            state.reviews = vec![round];
10407            let mut runner = Runner {
10408                state,
10409                roles: ResolvedRoles {
10410                    implementers: Vec::new(),
10411                    judges: Vec::new(),
10412                    reviewers: Vec::new(),
10413                    fixer: None,
10414                    conductor: conductor(),
10415                    implementer_roster: Vec::new(),
10416                    judge_roster: Vec::new(),
10417                    reviewer_roster: Vec::new(),
10418                },
10419                sem: Arc::new(Semaphore::new(1)),
10420                pause: Pause::new(),
10421                interrupt: Pause::new(),
10422            };
10423            let shell = runner.state.config.shell();
10424            runner
10425                .stop_reviewing("round budget spent", &shell, &repo)
10426                .await
10427                .expect("stop_reviewing");
10428            assert_eq!(runner.state.status, RunStatus::Gating);
10429            assert_eq!(
10430                runner.state.contested_handoff.is_some(),
10431                expect,
10432                "{severity:?} + {vote:?}"
10433            );
10434        }
10435    }
10436
10437    /// The shape the incident this whole fix responds to actually had: the
10438    /// round budget spent, the last round's own e2e blocked on the shared
10439    /// build cache (held here by a live pid — this test process — exactly
10440    /// `cache`'s own unit tests' pattern for "another owner, still alive"
10441    /// without forking a process). `stop_reviewing` must retry it — not
10442    /// silently leave the round looking untouched (the catch-up-only half of
10443    /// the bug), and not read the contention as a red `e2e` and block the
10444    /// run on it (the other half). Called directly, the same way
10445    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
10446    /// above exercises `gate`, so this never needs a real cargo build to
10447    /// reach: the lease is never released, so `with_cache_lease` never gets
10448    /// past acquiring it into anything that would need a real workspace.
10449    #[tokio::test]
10450    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10451        crate::run::pin_test_home();
10452        let home = crate::run::home();
10453
10454        let tmp = tempfile::tempdir().expect("tempdir");
10455        let repo = tmp.path().join("repo");
10456        std::fs::create_dir_all(&repo).unwrap();
10457        init_repo(&repo);
10458        let head = crate::git::rev_parse(&repo, "HEAD")
10459            .await
10460            .expect("rev-parse");
10461        // Unique to this test, so holding its lease cannot collide with
10462        // another test sharing the same process-wide `home`.
10463        let cache_dir = tmp.path().join("target");
10464
10465        let mut config = Config::default();
10466        config.verify.e2e = vec![format!(
10467            "CARGO_TARGET_DIR='{}' test -f README.md",
10468            cache_dir.display()
10469        )];
10470        config.graph.review_rounds = 1;
10471        // Bounded so a regression that does start waiting fails the test in
10472        // seconds, not hangs it.
10473        config.graph.timeout_verify = Some(2);
10474
10475        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10476        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10477            .expect("no io error acquiring directly")
10478        {
10479            crate::cache::AcquireOutcome::Acquired(g) => g,
10480            crate::cache::AcquireOutcome::Busy(b) => {
10481                panic!("expected the direct acquire to win the lease first: {b:?}")
10482            }
10483        };
10484
10485        let mut state = RunState::new(
10486            repo.clone(),
10487            "main".to_owned(),
10488            head.clone(),
10489            "task".to_owned(),
10490            config,
10491        );
10492        state.candidates = vec![Candidate {
10493            index: 0,
10494            label: 'A',
10495            agent: "alpha".to_owned(),
10496            branch: "does-not-exist".to_owned(),
10497            worktree: repo.clone(),
10498            summary: String::new(),
10499            stat: String::new(),
10500            files: 0,
10501            commits: 0,
10502            empty: false,
10503            failed: None,
10504            verified_noop: None,
10505            duration_ms: 0,
10506            folded: false,
10507        }];
10508        state.tally = Some(Tally {
10509            first_choice: BTreeMap::from([('A', 1)]),
10510            borda: BTreeMap::new(),
10511            winner: 'A',
10512            rankings: 1,
10513            unanimous_initial: true,
10514            deliberated: false,
10515            changed_votes: 0,
10516            unanimous_final: true,
10517            tie_break: None,
10518            judges: 0,
10519            present: 0,
10520            quorum: 0,
10521            met_quorum: true,
10522            uncontested: Some("only candidate A produced a change".to_owned()),
10523        });
10524        // The round budget's last round, deferred: `needs_catchup_run`'s
10525        // other trigger. `stop_reviewing`'s retry machinery must treat this
10526        // exactly like a resource-blocked attempt once it actually runs.
10527        state.reviews = vec![ReviewRound {
10528            round: 1,
10529            head: head.clone(),
10530            verified_head: None,
10531            verified_at: None,
10532            reviews: Vec::new(),
10533            e2e: Vec::new(),
10534            fix: None,
10535            blocking: 1,
10536            answered: 1,
10537            expected: 1,
10538            clean: false,
10539            verify_retried: false,
10540            e2e_deferred: true,
10541            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
10542            progressed: false,
10543            vote_split: false,
10544            reconsideration: Vec::new(),
10545            verdict: None,
10546        }];
10547
10548        let mut runner = Runner {
10549            state,
10550            roles: ResolvedRoles {
10551                implementers: Vec::new(),
10552                judges: Vec::new(),
10553                reviewers: Vec::new(),
10554                fixer: None,
10555                conductor: conductor(),
10556                implementer_roster: Vec::new(),
10557                judge_roster: Vec::new(),
10558                reviewer_roster: Vec::new(),
10559            },
10560            sem: Arc::new(Semaphore::new(1)),
10561            pause: Pause::new(),
10562            interrupt: Pause::new(),
10563        };
10564
10565        let shell = runner.state.config.shell();
10566        runner
10567            .stop_reviewing("round budget spent", &shell, &repo)
10568            .await
10569            .expect("stop_reviewing");
10570
10571        let last = runner.state.reviews.last().expect("round record");
10572        assert_eq!(
10573            last.e2e_status(),
10574            E2eStatus::ResourceBlocked,
10575            "the shared cache is still held; the attempt must read as blocked, not deferred or \
10576             failed: {last:?}"
10577        );
10578        assert_eq!(
10579            last.verified_head.as_deref(),
10580            Some(head.as_str()),
10581            "which commit this attempt targeted is known even though nothing finished checking \
10582             it"
10583        );
10584        let first_attempt_at = last
10585            .verified_at
10586            .expect("when this attempt ran is known too");
10587        assert_ne!(
10588            runner.state.status,
10589            RunStatus::Blocked,
10590            "contention is evidence about the machine, not the patch — it must not settle the \
10591             run as blocked: {:?}",
10592            runner.state.status
10593        );
10594        assert!(
10595            !runner
10596                .state
10597                .events
10598                .iter()
10599                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
10600            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
10601            runner.state.events
10602        );
10603
10604        // The cache is still held: a later reentry must retry the same
10605        // round's verification again — not leave it looking exactly as
10606        // untouched as the first blocked attempt, which is indistinguishable
10607        // from never having tried again at all.
10608        runner
10609            .stop_reviewing("round budget spent", &shell, &repo)
10610            .await
10611            .expect("stop_reviewing retry");
10612        assert_eq!(
10613            runner.state.reviews.len(),
10614            1,
10615            "no new round was started: {:?}",
10616            runner.state.reviews
10617        );
10618        let last = runner.state.reviews.last().expect("round record");
10619        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
10620        assert!(
10621            last.verified_at.expect("still known") > first_attempt_at,
10622            "a second reentry must be a fresh attempt, not a stale copy of the first"
10623        );
10624        assert_ne!(runner.state.status, RunStatus::Blocked);
10625
10626        held.release();
10627    }
10628
10629    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
10630    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
10631    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
10632    /// own top-of-function fast path (`review_conclusion`) correctly reads
10633    /// this shape as `None` rather than guessing `Blocked`, and the loop's
10634    /// own `for` range is empty once the round budget is spent, so
10635    /// `review_loop` must retry the check itself rather than silently doing
10636    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
10637    /// above exercises directly, but through `review_loop`'s own entry point
10638    /// this time, proving the wiring between the two rather than just the
10639    /// retry logic in isolation.
10640    #[tokio::test]
10641    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
10642        crate::run::pin_test_home();
10643        let home = crate::run::home();
10644
10645        let tmp = tempfile::tempdir().expect("tempdir");
10646        let repo = tmp.path().join("repo");
10647        std::fs::create_dir_all(&repo).unwrap();
10648        init_repo(&repo);
10649        let head = crate::git::rev_parse(&repo, "HEAD")
10650            .await
10651            .expect("rev-parse");
10652        let cache_dir = tmp.path().join("target");
10653
10654        let mut config = Config::default();
10655        config.verify.e2e = vec![format!(
10656            "CARGO_TARGET_DIR='{}' test -f README.md",
10657            cache_dir.display()
10658        )];
10659        config.graph.review_rounds = 1;
10660        config.graph.timeout_verify = Some(2);
10661
10662        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10663        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10664            .expect("no io error acquiring directly")
10665        {
10666            crate::cache::AcquireOutcome::Acquired(g) => g,
10667            crate::cache::AcquireOutcome::Busy(b) => {
10668                panic!("expected the direct acquire to win the lease first: {b:?}")
10669            }
10670        };
10671
10672        let mut state = RunState::new(
10673            repo.clone(),
10674            "main".to_owned(),
10675            head.clone(),
10676            "task".to_owned(),
10677            config,
10678        );
10679        state.candidates = vec![Candidate {
10680            index: 0,
10681            label: 'A',
10682            agent: "alpha".to_owned(),
10683            branch: "does-not-exist".to_owned(),
10684            worktree: repo.clone(),
10685            summary: String::new(),
10686            stat: String::new(),
10687            files: 0,
10688            commits: 0,
10689            empty: false,
10690            failed: None,
10691            verified_noop: None,
10692            duration_ms: 0,
10693            folded: false,
10694        }];
10695        state.tally = Some(Tally {
10696            first_choice: BTreeMap::from([('A', 1)]),
10697            borda: BTreeMap::new(),
10698            winner: 'A',
10699            rankings: 1,
10700            unanimous_initial: true,
10701            deliberated: false,
10702            changed_votes: 0,
10703            unanimous_final: true,
10704            tie_break: None,
10705            judges: 0,
10706            present: 0,
10707            quorum: 0,
10708            met_quorum: true,
10709            uncontested: Some("only candidate A produced a change".to_owned()),
10710        });
10711        // The exact shape a prior process's `stop_reviewing` would have left
10712        // on disk: the round budget's last round, a real attempt already
10713        // made and already resource-blocked.
10714        state.reviews = vec![ReviewRound {
10715            round: 1,
10716            head: head.clone(),
10717            verified_head: Some(head.clone()),
10718            verified_at: Some(jiff::Timestamp::now()),
10719            reviews: Vec::new(),
10720            e2e: vec![CommandOutcome {
10721                command: format!(
10722                    "CARGO_TARGET_DIR='{}' test -f README.md",
10723                    cache_dir.display()
10724                ),
10725                code: None,
10726                output_tail: "waiting for the shared build cache".to_owned(),
10727                duration_ms: 0,
10728                resource_blocked: true,
10729            }],
10730            fix: None,
10731            blocking: 1,
10732            answered: 1,
10733            expected: 1,
10734            clean: false,
10735            verify_retried: false,
10736            e2e_deferred: false,
10737            e2e_defer_reason: None,
10738            progressed: false,
10739            vote_split: false,
10740            reconsideration: Vec::new(),
10741            verdict: None,
10742        }];
10743
10744        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
10745        let mut runner = Runner {
10746            state,
10747            roles: ResolvedRoles {
10748                implementers: Vec::new(),
10749                judges: Vec::new(),
10750                reviewers: Vec::new(),
10751                fixer: None,
10752                conductor: conductor(),
10753                implementer_roster: Vec::new(),
10754                judge_roster: Vec::new(),
10755                reviewer_roster: Vec::new(),
10756            },
10757            sem: Arc::new(Semaphore::new(1)),
10758            pause: Pause::new(),
10759            interrupt: Pause::new(),
10760        };
10761
10762        // The lease is still held throughout, so this reentry's own retry is
10763        // also contended — proving `review_loop` actually tried again (not
10764        // that it happened to succeed) is what the timestamp comparison
10765        // below is for.
10766        runner.review_loop().await.expect("review_loop");
10767
10768        assert_eq!(
10769            runner.state.reviews.len(),
10770            1,
10771            "no new round was started on top of the unresolved one: {:?}",
10772            runner.state.reviews
10773        );
10774        let last = &runner.state.reviews[0];
10775        assert_eq!(
10776            last.e2e_status(),
10777            E2eStatus::ResourceBlocked,
10778            "still contended: {last:?}"
10779        );
10780        assert!(
10781            last.verified_at.expect("still known") > first_attempt_at,
10782            "review_loop must have actually retried the check, not left it exactly as found"
10783        );
10784        assert_ne!(
10785            runner.state.status,
10786            RunStatus::Blocked,
10787            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
10788            runner.state.status
10789        );
10790
10791        held.release();
10792    }
10793
10794    #[tokio::test]
10795    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
10796        crate::run::pin_test_home();
10797        let tmp = tempfile::tempdir().expect("tempdir");
10798        let repo = tmp.path().join("repo");
10799        std::fs::create_dir_all(&repo).unwrap();
10800        init_repo(&repo);
10801
10802        let mut config = Config::default();
10803        config.merge.mode = MergeMode::Pr;
10804        config.graph.land = true;
10805        config.graph.land_approval = false;
10806
10807        let mut state = RunState::new(
10808            repo.clone(),
10809            "main".to_owned(),
10810            "deadbeef".to_owned(),
10811            "task".to_owned(),
10812            config,
10813        );
10814        state.candidates = vec![Candidate {
10815            index: 0,
10816            label: 'A',
10817            agent: "alpha".to_owned(),
10818            branch: "does-not-exist".to_owned(),
10819            worktree: repo.clone(),
10820            summary: String::new(),
10821            stat: String::new(),
10822            files: 0,
10823            commits: 0,
10824            empty: false,
10825            failed: None,
10826            verified_noop: None,
10827            duration_ms: 0,
10828            folded: false,
10829        }];
10830        state.tally = Some(Tally {
10831            first_choice: BTreeMap::from([('A', 1)]),
10832            borda: BTreeMap::new(),
10833            winner: 'A',
10834            rankings: 1,
10835            unanimous_initial: true,
10836            deliberated: false,
10837            changed_votes: 0,
10838            unanimous_final: true,
10839            tie_break: None,
10840            judges: 0,
10841            present: 0,
10842            quorum: 0,
10843            met_quorum: true,
10844            uncontested: Some("only candidate A produced a change".to_owned()),
10845        });
10846        state.reviews = vec![ReviewRound {
10847            round: 1,
10848            head: "deadbeef".to_owned(),
10849            verified_head: None,
10850            verified_at: None,
10851            reviews: Vec::new(),
10852            e2e: Vec::new(),
10853            fix: None,
10854            blocking: 0,
10855            answered: 0,
10856            expected: 0,
10857            clean: true,
10858            verify_retried: false,
10859            e2e_deferred: false,
10860            e2e_defer_reason: None,
10861            progressed: false,
10862            vote_split: false,
10863            reconsideration: Vec::new(),
10864            verdict: None,
10865        }];
10866        state.gate = vec![CommandOutcome {
10867            command: "test".to_owned(),
10868            code: Some(0),
10869            output_tail: String::new(),
10870            duration_ms: 0,
10871            resource_blocked: false,
10872        }];
10873        state.gate_ran = true;
10874        // A first pass through `merge` already pushed and opened this pull
10875        // request; `status` is `Landing` because a previous call into `land`
10876        // parked or was interrupted before it reached a terminal outcome.
10877        state.status = RunStatus::Landing;
10878        state.merge = Some(MergeOutcome {
10879            mode: MergeMode::Pr,
10880            ok: true,
10881            detail: "https://example.invalid/x/y/pull/1".to_owned(),
10882            empty: false,
10883        });
10884
10885        // The Landing-resume shortcut calls `run_land` directly rather than
10886        // through `merge`, which is exactly the call site that used to skip
10887        // `settle_questions` - see the fixture below.
10888        ask_test_home();
10889        let store = ask::Questions::open();
10890        let q = ask_open_question(&store, &state.id);
10891
10892        let mut runner = Runner {
10893            state,
10894            roles: ResolvedRoles {
10895                implementers: Vec::new(),
10896                judges: Vec::new(),
10897                reviewers: Vec::new(),
10898                fixer: None,
10899                conductor: conductor(),
10900                implementer_roster: Vec::new(),
10901                judge_roster: Vec::new(),
10902                reviewer_roster: Vec::new(),
10903            },
10904            sem: Arc::new(Semaphore::new(1)),
10905            pause: Pause::new(),
10906            interrupt: Pause::new(),
10907        };
10908
10909        // `execute`, not `merge` directly: the Landing-resume shortcut lives
10910        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
10911        // exactly because `review_loop` would otherwise clobber the marker
10912        // first.
10913        runner.execute().await.expect("execute");
10914
10915        assert_eq!(
10916            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10917            Some("https://example.invalid/x/y/pull/1"),
10918            "reentry must not push again or open a second pull request over the \
10919             one `land` is already watching"
10920        );
10921        assert_ne!(
10922            runner.state.status,
10923            RunStatus::Landing,
10924            "land could not actually reach the fake pull request, so it must \
10925             have given up rather than left the run silently parked forever"
10926        );
10927        // `land` could not reach the fake pull request, so it gave up into
10928        // `Blocked` - still resumable, so the question must not have been
10929        // swept just because this branch now also calls `settle_questions`.
10930        assert_eq!(runner.state.status, RunStatus::Blocked);
10931        assert!(
10932            store.get(&q.id).unwrap().status.open(),
10933            "Blocked is still alive; settle_questions must have been a no-op here"
10934        );
10935    }
10936
10937    fn state_with_round(round: ReviewRound) -> RunState {
10938        let mut s = RunState::new(
10939            PathBuf::from("/repo"),
10940            "main".to_owned(),
10941            "abc1234".to_owned(),
10942            "add retries".to_owned(),
10943            Config::default(),
10944        );
10945        s.reviews = vec![round];
10946        s
10947    }
10948
10949    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
10950        crate::verdict::Finding {
10951            id: id.to_owned(),
10952            severity,
10953            file: None,
10954            line: None,
10955            title: title.to_owned(),
10956            detail: String::new(),
10957        }
10958    }
10959
10960    #[test]
10961    fn pr_body_names_open_findings_and_declined_ones() {
10962        let round = ReviewRound {
10963            round: 2,
10964            head: "deadbee".to_owned(),
10965            verified_head: None,
10966            verified_at: None,
10967            reviews: vec![ReviewRecord {
10968                attempts: 0,
10969                reviewer: 1,
10970                agent: "alpha".to_owned(),
10971                summary: String::new(),
10972                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
10973                vote: None,
10974                failed: None,
10975                duration_ms: 0,
10976            }],
10977            e2e: vec![CommandOutcome {
10978                command: "cargo test".to_owned(),
10979                code: Some(0),
10980                output_tail: String::new(),
10981                duration_ms: 0,
10982                resource_blocked: false,
10983            }],
10984            verify_retried: false,
10985            e2e_deferred: false,
10986            e2e_defer_reason: None,
10987            fix: Some(FixRecord {
10988                agent: "alpha".to_owned(),
10989                addressed: Vec::new(),
10990                rejected: vec![crate::verdict::Rejection {
10991                    id: "R1-1-1".to_owned(),
10992                    why: "not reachable from any caller".to_owned(),
10993                }],
10994                notes: String::new(),
10995                committed: true,
10996                failed: None,
10997                duration_ms: 0,
10998                continuation: None,
10999            }),
11000            blocking: 0,
11001            answered: 1,
11002            expected: 1,
11003            clean: false,
11004            progressed: true,
11005            vote_split: false,
11006            reconsideration: Vec::new(),
11007            verdict: None,
11008        };
11009        let state = state_with_round(round);
11010        let body = pr_message(&state, 'A').body;
11011
11012        assert!(body.contains("add retries"), "the task must still be there");
11013        assert!(body.contains("R2-1-1"), "{body}");
11014        assert!(body.contains("unused import"), "{body}");
11015        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11016        assert!(
11017            body.contains("not reachable from any caller"),
11018            "the reason it was declined: {body}"
11019        );
11020    }
11021
11022    #[test]
11023    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11024        let round = ReviewRound {
11025            round: 1,
11026            head: "deadbee".to_owned(),
11027            verified_head: None,
11028            verified_at: None,
11029            reviews: vec![ReviewRecord {
11030                attempts: 0,
11031                reviewer: 1,
11032                agent: "alpha".to_owned(),
11033                summary: String::new(),
11034                findings: Vec::new(),
11035                vote: None,
11036                failed: None,
11037                duration_ms: 0,
11038            }],
11039            e2e: Vec::new(),
11040            verify_retried: false,
11041            e2e_deferred: false,
11042            e2e_defer_reason: None,
11043            fix: None,
11044            blocking: 0,
11045            answered: 1,
11046            expected: 1,
11047            clean: true,
11048            progressed: false,
11049            vote_split: false,
11050            reconsideration: Vec::new(),
11051            verdict: None,
11052        };
11053        let state = state_with_round(round);
11054        let body = pr_message(&state, 'A').body;
11055        assert!(!body.contains("Open review findings"), "{body}");
11056        assert!(!body.contains("Declined"), "{body}");
11057    }
11058
11059    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11060        let mut state = RunState::new(
11061            PathBuf::from("/repo"),
11062            "main".to_owned(),
11063            "abc1234".to_owned(),
11064            instruction.to_owned(),
11065            Config::default(),
11066        );
11067        state.candidates.push(Candidate {
11068            index: 0,
11069            label: 'A',
11070            agent: "alpha".to_owned(),
11071            branch: "magi/x/A".to_owned(),
11072            worktree: PathBuf::from("/wt"),
11073            summary: summary.to_owned(),
11074            stat: String::new(),
11075            files: 1,
11076            commits: 1,
11077            empty: false,
11078            failed: None,
11079            verified_noop: None,
11080            folded: false,
11081            duration_ms: 0,
11082        });
11083        state
11084    }
11085
11086    fn review_state(subjects: &[&str]) -> RunState {
11087        let mut state = state_with_summary(
11088            "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",
11089            "",
11090        );
11091        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11092        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11093        state
11094    }
11095
11096    /// A branch rebased onto a main that moved past the recorded
11097    /// `base_commit` is titled from its own first commit, never main's.
11098    #[tokio::test]
11099    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11100        ask_test_home();
11101        let tmp = tempfile::tempdir().unwrap();
11102        let repo = tmp.path().join("repo");
11103        std::fs::create_dir_all(&repo).unwrap();
11104        init_repo(&repo);
11105        let origin = tmp.path().join("origin.git");
11106        let g = |dir: &Path, args: &[&str]| {
11107            let out = std::process::Command::new("git")
11108                .args(args)
11109                .current_dir(dir)
11110                .quiet()
11111                .output()
11112                .expect("spawn git");
11113            assert!(
11114                out.status.success(),
11115                "git {args:?}: {}",
11116                String::from_utf8_lossy(&out.stderr)
11117            );
11118        };
11119        g(
11120            tmp.path(),
11121            &[
11122                "clone",
11123                "--bare",
11124                "-q",
11125                repo.to_str().unwrap(),
11126                origin.to_str().unwrap(),
11127            ],
11128        );
11129        g(
11130            &repo,
11131            &["remote", "add", "origin", origin.to_str().unwrap()],
11132        );
11133        let c1 = git::rev_parse(&repo, "main").await.unwrap();
11134
11135        // Main moves on; the branch is built on top of the new main.
11136        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11137        g(&repo, &["add", "-A"]);
11138        g(
11139            &repo,
11140            &["commit", "-q", "-m", "chore(deps): update a crate"],
11141        );
11142        g(&repo, &["push", "-q", "origin", "main"]);
11143        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11144        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11145        g(&repo, &["add", "-A"]);
11146        g(
11147            &repo,
11148            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11149        );
11150        g(&repo, &["checkout", "-q", "main"]);
11151
11152        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11153        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11154        // Without a readable tracking ref the recorded base's merge base is used.
11155        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11156        assert_eq!(fallback, c1);
11157
11158        let mut state = review_state(&[
11159            "chore(deps): update a crate",
11160            "fix(daemon): apply a chosen action",
11161        ]);
11162        state.repo = repo.clone();
11163        state.base_branch = "main".to_owned();
11164        state.base_commit = c1;
11165        refresh_reviewed_commits(&mut state, "feat/own").await;
11166        assert_eq!(
11167            state.reviewed_commits,
11168            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11169        );
11170        assert_eq!(
11171            review_title(&state).as_deref(),
11172            Some("fix(daemon): apply a chosen action")
11173        );
11174        assert_eq!(
11175            leaked_subjects(&state, "feat/own").await,
11176            Some(vec!["chore(deps): update a crate".to_owned()])
11177        );
11178        // A stale tracking ref is still used when the fetch fails, but the
11179        // leak list is withheld.
11180        state.config.merge.remote = "nowhere".to_owned();
11181        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11182    }
11183
11184    #[test]
11185    fn pr_message_review_single_commit_uses_its_subject() {
11186        let state = review_state(&["feat(nats): per-role user"]);
11187        let m = pr_message(&state, 'A');
11188        assert_eq!(m.title, "feat(nats): per-role user");
11189        assert!(!m.body.contains("Review the work already"), "{}", m.body);
11190        assert!(m.body.contains("## Commits under review"), "{}", m.body);
11191    }
11192
11193    #[test]
11194    fn pr_message_review_multi_commit_takes_the_oldest() {
11195        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11196        let m = pr_message(&state, 'A');
11197        assert_eq!(m.title, "feat: the change");
11198        for s in ["feat: the change", "fix: typo", "fix: again"] {
11199            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11200        }
11201    }
11202
11203    #[test]
11204    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11205        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11206            let mut state = review_state(&[first, "fix: later fixup"]);
11207            state.candidates[0].branch = "機能/ログイン".to_owned();
11208            let m = pr_message(&state, 'A');
11209            assert!(
11210                m.title.starts_with("chore: land candidate A of run"),
11211                "{}",
11212                m.title
11213            );
11214        }
11215    }
11216
11217    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11218        BranchFacts {
11219            commits: commits
11220                .iter()
11221                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11222                .collect(),
11223            stat: stat.to_owned(),
11224        }
11225    }
11226
11227    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11228        let mut r = review_round(true, 0, 1, 1, true, true);
11229        r.round = round;
11230        r.fix = notes.map(|n| FixRecord {
11231            agent: "fixer".to_owned(),
11232            addressed: Vec::new(),
11233            rejected: Vec::new(),
11234            notes: n.to_owned(),
11235            committed: true,
11236            failed: None,
11237            duration_ms: 0,
11238            continuation: None,
11239        });
11240        r
11241    }
11242
11243    #[test]
11244    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11245        let state = review_state(&["ignored"]);
11246        let f = facts(
11247            &[
11248                (
11249                    "fix(login): resolve PATH on macOS",
11250                    "Login shells skip rc files.",
11251                ),
11252                ("fix: address review", ""),
11253            ],
11254            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11255        );
11256        let m = pr_message_with(&state, 'A', Some(&f));
11257        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11258        assert!(
11259            m.body
11260                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
11261            "{}",
11262            m.body
11263        );
11264        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11265        assert!(m.body.contains("## Diff stat"), "{}", m.body);
11266        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11267        for banned in [
11268            "Review the work already",
11269            "no task statement",
11270            "Original task",
11271        ] {
11272            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11273        }
11274        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11275    }
11276
11277    #[test]
11278    fn pr_message_review_truncates_a_huge_first_commit_body() {
11279        let state = review_state(&["ignored"]);
11280        let f = facts(
11281            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11282            "s",
11283        );
11284        let m = pr_message_with(&state, 'A', Some(&f));
11285        assert!(m.body.len() < 30_000, "{}", m.body.len());
11286        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11287        assert!(
11288            m.body.contains("- ... 1 more commit(s)"),
11289            "{}",
11290            m.body.len()
11291        );
11292        assert!(m.body.ends_with("magi:candidate-a\n"));
11293    }
11294
11295    #[test]
11296    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11297        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11298        assert_eq!(m.title, "feat: x");
11299        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11300        assert!(!m.body.contains("Diff stat"), "{}", m.body);
11301        assert!(!m.body.contains("no task statement"), "{}", m.body);
11302    }
11303
11304    #[test]
11305    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11306        let mut state = review_state(&["日本語の件名"]);
11307        state.candidates[0].branch = "fix/macos-login-path".to_owned();
11308        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11309        state.candidates[0].branch = "機能/ログイン".to_owned();
11310        assert!(
11311            pr_message(&state, 'A')
11312                .title
11313                .starts_with("chore: land candidate A")
11314        );
11315    }
11316
11317    #[test]
11318    fn pr_message_review_fixes_survive_a_clean_final_round() {
11319        let mut state = review_state(&["feat: x"]);
11320        state.reviews = vec![
11321            round_with_notes(1, Some("handled the PATH case")),
11322            round_with_notes(2, None),
11323        ];
11324        let body = pr_message(&state, 'A').body;
11325        assert!(
11326            body.contains("## Review fixes\n\nhandled the PATH case\n"),
11327            "{body}"
11328        );
11329        assert!(!body.contains("### Round"), "{body}");
11330
11331        state.reviews = vec![
11332            round_with_notes(1, Some("first fix")),
11333            round_with_notes(2, Some("")),
11334            round_with_notes(3, Some("second fix")),
11335            round_with_notes(4, None),
11336        ];
11337        let body = pr_message(&state, 'A').body;
11338        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11339        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11340        assert!(!body.contains("### Round 2"), "{body}");
11341    }
11342
11343    #[test]
11344    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11345        let state = state_with_summary(
11346            "add retries to the client",
11347            "TITLE: feat: retries\n\nDid it.",
11348        );
11349        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11350        assert_eq!(m.title, "feat: retries");
11351        assert!(
11352            m.body.contains("<summary>Original task</summary>"),
11353            "{}",
11354            m.body
11355        );
11356        assert!(!m.body.contains("Commits under review"), "{}", m.body);
11357        assert!(
11358            m.body
11359                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11360            "{}",
11361            m.body
11362        );
11363    }
11364
11365    #[test]
11366    fn pr_message_review_bounds_a_long_english_subject() {
11367        let long = format!("feat: {}", "word ".repeat(100));
11368        let m = pr_message(&review_state(&[&long]), 'A');
11369        assert!(m.title.starts_with("feat: word"), "{}", m.title);
11370        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11371    }
11372
11373    #[test]
11374    fn pr_message_implementation_run_is_unchanged_by_review_support() {
11375        let state = state_with_summary("add retries\n\ndetails", "- did some things");
11376        let m = pr_message(&state, 'A');
11377        assert_eq!(m.title, "add retries");
11378        assert!(m.body.contains("<summary>Original task</summary>"));
11379        assert!(!m.body.contains("Commits under review"));
11380        assert_eq!(landing_subject_source(&state), state.instruction);
11381    }
11382
11383    #[test]
11384    fn review_run_squash_subject_is_the_change_not_the_prompt() {
11385        let state = review_state(&["feat: the change", "fix: typo"]);
11386        let source = landing_subject_source(&state);
11387        assert_eq!(land::merge_subject("", &source), "feat: the change");
11388        assert_eq!(
11389            land::merge_subject("magi: candidate A (uncommitted work)", &source),
11390            "feat: the change"
11391        );
11392        // An operator's rename still wins.
11393        assert_eq!(
11394            land::merge_subject("feat: renamed by hand", &source),
11395            "feat: renamed by hand"
11396        );
11397        let blank = review_state(&["日本語"]);
11398        assert!(
11399            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11400        );
11401    }
11402
11403    #[test]
11404    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11405        let state = review_state(&["feat: the change"]);
11406        let source = landing_subject_source(&state);
11407        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11408        assert_eq!(
11409            land::merge_subject(landing_title(&state, old), &source),
11410            "feat: the change"
11411        );
11412        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11413        let task = state_with_summary("add retries", "");
11414        assert_eq!(landing_title(&task, old), old);
11415    }
11416
11417    #[test]
11418    fn pr_message_describes_the_change_not_the_task() {
11419        let state = state_with_summary(
11420            "今回やってほしいこと: results projector を直す",
11421            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11422        );
11423        let m = pr_message(&state, 'A');
11424        assert_eq!(m.title, "fix(web): batch the runs list reads");
11425        assert!(
11426            m.body.starts_with("## Summary\n\n- reads run.json once"),
11427            "{}",
11428            m.body
11429        );
11430        assert!(!m.body.contains("TITLE:"), "{}", m.body);
11431        let task_at = m.body.find("今回やってほしいこと").unwrap();
11432        let details_at = m.body.find("<details>").unwrap();
11433        assert!(
11434            details_at < task_at,
11435            "the task lives inside <details>: {}",
11436            m.body
11437        );
11438        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11439        assert!(m.body.contains("magi:candidate-a"));
11440    }
11441
11442    #[test]
11443    fn pr_message_falls_back_to_the_task_without_a_title_line() {
11444        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11445        let m = pr_message(&state, 'A');
11446        assert_eq!(m.title, "add retries");
11447        assert!(
11448            m.body.contains("## Summary\n\n- did some things"),
11449            "{}",
11450            m.body
11451        );
11452
11453        let none = RunState::new(
11454            PathBuf::from("/repo"),
11455            "main".to_owned(),
11456            "abc1234".to_owned(),
11457            "add retries".to_owned(),
11458            Config::default(),
11459        );
11460        let m = pr_message(&none, 'A');
11461        assert_eq!(m.title, "add retries");
11462        assert!(!m.body.contains("## Summary"), "{}", m.body);
11463    }
11464
11465    #[test]
11466    fn pr_message_refuses_the_candidate_commit_subject() {
11467        for bad in [
11468            "TITLE: magi: candidate A (uncommitted work)",
11469            "TITLE: chore: stuff (uncommitted work)",
11470            "TITLE:   ",
11471        ] {
11472            let state = state_with_summary("add retries", bad);
11473            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
11474        }
11475    }
11476
11477    #[test]
11478    fn pr_message_bounds_a_very_long_task_and_title() {
11479        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
11480        let state = state_with_summary(&long, "- nothing");
11481        let m = pr_message(&state, 'A');
11482        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11483        assert!(!m.title.contains('\n'));
11484
11485        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
11486        let m = pr_message(&state, 'A');
11487        assert!(m.title.starts_with("feat: "));
11488        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11489        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
11490    }
11491
11492    fn long_title_of(instruction: &str) -> String {
11493        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
11494    }
11495
11496    #[test]
11497    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
11498        let first = "Make the landing path keep a readable title for long tasks";
11499        let line = format!(
11500            "{first}. {}",
11501            "And then keep going with more words ".repeat(20)
11502        );
11503        let t = long_title_of(&line);
11504        assert_eq!(t, first);
11505        assert!(!t.starts_with("chore: land"));
11506    }
11507
11508    #[test]
11509    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
11510        let line = "word ".repeat(200);
11511        let t = long_title_of(&line);
11512        assert!(t.ends_with("word..."), "{t}");
11513        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
11514    }
11515
11516    #[test]
11517    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
11518        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
11519            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
11520        }
11521    }
11522
11523    #[test]
11524    fn pr_message_title_limit_is_exact() {
11525        let at = "a".repeat(PR_TITLE_MAX);
11526        assert_eq!(long_title_of(&at), at);
11527        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
11528        assert!(over.ends_with("..."), "{over}");
11529        assert_eq!(over.chars().count(), PR_TITLE_MAX);
11530    }
11531
11532    #[test]
11533    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
11534        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
11535        let t = long_title_of(&line);
11536        assert!(t.ends_with("..."), "{t}");
11537        assert!(t.is_ascii(), "{t}");
11538    }
11539
11540    #[test]
11541    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
11542        let line = format!(
11543            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
11544            "plus more filler words ".repeat(20)
11545        );
11546        let t = long_title_of(&line);
11547        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
11548    }
11549
11550    #[test]
11551    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
11552        let line = format!("\n\n# {}", "title words ".repeat(40));
11553        let state = state_with_summary(&line, "- nothing");
11554        let m = pr_message(&state, 'A');
11555        assert!(m.title.starts_with("title words"), "{}", m.title);
11556        assert_eq!(
11557            land::merge_subject(&m.title, &landing_subject_source(&state)),
11558            m.title
11559        );
11560        // An operator's rename wins untouched.
11561        assert_eq!(
11562            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
11563            "feat: renamed by hand"
11564        );
11565    }
11566
11567    #[test]
11568    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
11569        // What magi itself writes stays English under any configured language,
11570        // so a future localisation of these headings fails here. (The agents'
11571        // own text is held to English by the prompt only; magi cannot check it.)
11572        let mut state = state_with_summary(
11573            "add retries",
11574            "TITLE: fix(web): batch reads\n- reads run.json once",
11575        );
11576        state.config.graph.language = "ja".to_owned();
11577        let m = pr_message(&state, 'A');
11578        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
11579
11580        // The task is the operator's own text: it goes in untouched, and the
11581        // fallback title (no summary) may be in its language too.
11582        let task = "今回やってほしいこと: results projector を直す";
11583        let mut state = state_with_summary(task, "- no title line");
11584        state.config.graph.language = "ja".to_owned();
11585        let m = pr_message(&state, 'A');
11586        assert_eq!(
11587            m.title,
11588            format!("chore: land candidate A of run {}", state.id)
11589        );
11590        assert!(
11591            m.body.contains(&format!(
11592                "<summary>Original task</summary>\n\n{task}\n\n</details>"
11593            )),
11594            "{}",
11595            m.body
11596        );
11597    }
11598
11599    #[test]
11600    fn pr_message_scrubs_home_paths_and_addresses() {
11601        let state = state_with_summary(
11602            "fix it in /Users/someone/src/x",
11603            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
11604        );
11605        let m = pr_message(&state, 'A');
11606        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
11607            assert!(!m.body.contains(leak), "{}", m.body);
11608        }
11609        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
11610    }
11611
11612    #[test]
11613    fn pr_message_survives_a_task_that_closes_details() {
11614        let state = state_with_summary("a </details> b", "TITLE: fix: x");
11615        let m = pr_message(&state, 'A');
11616        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
11617    }
11618
11619    #[test]
11620    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
11621        let cmd = manual_merge_command(
11622            MergeStyle::Squash,
11623            Path::new("/repo"),
11624            "b",
11625            "fix: \"quoted\" $(x) `y`\n\nbody",
11626        );
11627        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
11628    }
11629
11630    #[test]
11631    fn manual_merge_command_matches_the_configured_style() {
11632        let repo = Path::new("/repo");
11633        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
11634
11635        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
11636        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
11637
11638        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
11639        assert_eq!(
11640            squash,
11641            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
11642             \"Merge magi run 0832 (candidate A)\""
11643        );
11644
11645        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
11646        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
11647    }
11648
11649    #[test]
11650    fn a_nudge_gets_a_quarter_of_the_budget() {
11651        // The judge and implement budgets magi ships with.
11652        assert_eq!(retry_budget(secs(1200), true), secs(300));
11653        assert_eq!(retry_budget(secs(3600), true), secs(900));
11654    }
11655
11656    #[test]
11657    fn a_resent_prompt_keeps_the_whole_budget() {
11658        // The seat kept no context, so the retry is the original job again and
11659        // shortening it would only guarantee a second failure.
11660        assert_eq!(retry_budget(secs(1200), false), secs(1200));
11661        assert_eq!(retry_budget(secs(60), false), secs(60));
11662    }
11663
11664    #[test]
11665    fn the_floor_never_exceeds_the_original_budget() {
11666        // A short configured timeout must not be *raised* by the floor: the
11667        // operator asked for a bound, and a retry may not outlast the attempt
11668        // it is retrying.
11669        assert_eq!(retry_budget(secs(60), true), secs(60));
11670        assert_eq!(retry_budget(secs(480), true), secs(120));
11671        assert_eq!(retry_budget(secs(0), true), secs(0));
11672    }
11673
11674    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
11675        agent::CommandEvidence {
11676            id: "item1".to_owned(),
11677            description: "cargo test".to_owned(),
11678            exit_code,
11679            result_summary: String::new(),
11680            source: "codex".to_owned(),
11681        }
11682    }
11683
11684    #[test]
11685    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
11686        // No evidence is not the same fact as unconfirmed evidence: a
11687        // backend with no adapter, or a reply that ran no commands at all,
11688        // must not be misread as carrying a dangling job.
11689        assert!(!has_unconfirmed_command(&[]));
11690    }
11691
11692    #[test]
11693    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
11694        // Deliberately not a check on the exit code's *value*: a fixer
11695        // legitimately runs something that fails mid-iteration before it
11696        // succeeds, and that must never by itself reopen a valid report.
11697        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
11698        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
11699        assert!(!has_unconfirmed_command(&[
11700            evidence(Some(0)),
11701            evidence(Some(101))
11702        ]));
11703    }
11704
11705    #[test]
11706    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
11707        assert!(has_unconfirmed_command(&[
11708            evidence(Some(0)),
11709            evidence(None)
11710        ]));
11711    }
11712
11713    #[test]
11714    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
11715        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11716        assert_eq!(
11717            verified_noop_claim(true, &[], text).as_deref(),
11718            Some("already fixed by b32cfc4, on main.")
11719        );
11720    }
11721
11722    #[test]
11723    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
11724        // A timeout or a bad exit code reads as the ordinary loss it is,
11725        // whatever the reply's own prose claims.
11726        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11727        assert!(verified_noop_claim(false, &[], text).is_none());
11728    }
11729
11730    #[test]
11731    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
11732        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
11733        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
11734        // A confirmed command alongside the marker is fine.
11735        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
11736    }
11737
11738    #[test]
11739    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
11740        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
11741    }
11742
11743    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
11744    /// pair, in order, labelled A, B, C, ...
11745    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
11746        runner.state.candidates = shape
11747            .iter()
11748            .enumerate()
11749            .map(|(i, &(empty, verified))| Candidate {
11750                index: i,
11751                label: (b'A' + i as u8) as char,
11752                agent: "sonnet".to_owned(),
11753                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
11754                worktree: PathBuf::from(format!("/wt/{i}")),
11755                summary: String::new(),
11756                stat: String::new(),
11757                files: 0,
11758                commits: 0,
11759                empty,
11760                failed: None,
11761                verified_noop: verified.map(str::to_owned),
11762                duration_ms: 0,
11763                folded: false,
11764            })
11765            .collect();
11766    }
11767
11768    #[test]
11769    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
11770        ask_test_home();
11771        let mut runner = runner_at(RunStatus::Implementing);
11772        set_candidates(
11773            &mut runner,
11774            &[
11775                (true, Some("already on main at b32cfc4")),
11776                (true, Some("same fix, see the existing test")),
11777            ],
11778        );
11779
11780        runner
11781            .after_implement()
11782            .expect("a verified no-op is not an error");
11783
11784        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
11785    }
11786
11787    #[test]
11788    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
11789        ask_test_home();
11790        let mut runner = runner_at(RunStatus::Implementing);
11791        // Candidate A declares a verified no-op; candidate B simply wrote
11792        // nothing and said nothing about why. One candidate's claim is not
11793        // the whole run's agreement.
11794        set_candidates(
11795            &mut runner,
11796            &[(true, Some("already on main at b32cfc4")), (true, None)],
11797        );
11798
11799        let err = runner
11800            .after_implement()
11801            .expect_err("an unverified empty candidate must still fail the run");
11802
11803        assert!(
11804            err.to_string().contains("no candidate produced a change"),
11805            "{err}"
11806        );
11807        assert_eq!(runner.state.status, RunStatus::Failed);
11808    }
11809
11810    #[test]
11811    fn after_implement_still_fails_an_ordinary_all_empty_run() {
11812        ask_test_home();
11813        let mut runner = runner_at(RunStatus::Implementing);
11814        set_candidates(&mut runner, &[(true, None), (true, None)]);
11815
11816        let err = runner
11817            .after_implement()
11818            .expect_err("no candidate declared anything; this is an ordinary failure");
11819
11820        assert!(
11821            err.to_string().contains("no candidate produced a change"),
11822            "{err}"
11823        );
11824        assert_eq!(runner.state.status, RunStatus::Failed);
11825    }
11826}