Skip to main content

magi/
graph.rs

1//! The competition graph.
2//!
3//! ```text
4//! prep ──► implement ×N ──► judge ×M (blind) ──► split? ──► deliberate ──► vote (private)
5//!                                                   │                          │
6//!                                                   └──── unanimous ───────────┤
7//!                                                                              ▼
8//!   merge ◄── gate ◄── review ×R + E2E, fix, repeat ◄── fold losers ◄──────── tally
9//! ```
10//!
11//! Every node persists before the next one starts, so a run can be resumed
12//! after a crash, a rate limit, or a reboot without re-spending the work that
13//! already landed.
14//!
15//! The design decision that matters most is *where the facilitator lives*.
16//! There is no moderator agent: magi assigns the labels, decides the
17//! presentation order, relays the transcript, and collects the final votes
18//! one-to-one. A moderator that never learns an author cannot leak one.
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use anyhow::{Context as _, Result, bail};
26use jiff::Timestamp;
27use tokio::sync::Semaphore;
28
29use crate::advise;
30use crate::agent::{self, AgentOutput, Invocation, SeatState};
31use crate::ask;
32use crate::blind;
33use crate::bump;
34use crate::config::{
35    AgentSpec, Config, IncompleteReviewPolicy, LeakPolicy, MergeMode, MergeStyle, Prompts,
36    ResolvedRoles,
37};
38use crate::fixer;
39use crate::git;
40use crate::land;
41use crate::proc::Quiet as _;
42use crate::prompt::{
43    self, CandidateView, Lens, ReviewPatch, ReviewReconsiderCtx, ReviewSeatReport, Turn,
44};
45use crate::queue;
46use crate::refs;
47use crate::run::{
48    BaseSync, Candidate, CommandOutcome, ContinuationOutcome, ContinuationRecord,
49    DeliberationRound, DeliberationTurn, E2eStatus, FailClass, FixRecord, GateFixRecord, Handover,
50    JobRecord, JobStatus, Judgement, MergeOutcome, OperatorFixFinding, OperatorFixOutcome,
51    OperatorFixRequest, Origin, QuotaLoss, ReviewRecord, ReviewRevoteRecord, ReviewRound, RunState,
52    RunStatus, SeatHistory, Tally, VoteRecord, tail, write_artifact,
53};
54use crate::verdict::{
55    self, FinalVote, Finding, FixReport, Position, Proposal, Ranking, Review, ReviewRevote,
56    ReviewVote, Severity,
57};
58use crate::worktree_setup;
59
60/// How much verification output is kept and fed back to the fixer.
61const OUTPUT_TAIL: usize = 8_000;
62
63/// Bytes of a failing command's output kept in an event, so the reason a run
64/// stopped is readable from the report without opening `run.json`.
65const EVENT_OUTPUT_TAIL: usize = 2_000;
66
67/// How often [`wait_for_timed_out_children_to_die`] re-checks a timed-out
68/// command's pid before releasing the build cache's lease.
69const LEASE_RELEASE_POLL: Duration = Duration::from_secs(1);
70
71/// The most [`wait_for_timed_out_children_to_die`] will wait for a timed-out
72/// command's pid to actually exit before giving up and releasing anyway.
73///
74/// A timeout means the process was asked to die (`kill_on_drop`,
75/// `start_kill`), not that it already has — on Windows in particular that can
76/// take a moment, the same reason `agent`'s own `PIPE_GRACE` exists. Releasing
77/// the instant the command returns would let the very next acquirer (this
78/// run's own next round, another run's verification, the janitor's prune)
79/// start touching the same directory while it might still be writing to it,
80/// so this polls the actual pid — real confirmation, not a fixed guess —
81/// until it is gone or this ceiling is reached. It is still not full
82/// process-tree reaping: a grandchild the timed-out process spawned and that
83/// outlives it independently is invisible to a pid check, and continuing to
84/// observe and collect *that* stays a different piece of work with its own
85/// owner. Set generously because the common case returns early the moment
86/// the pid is confirmed gone, not because every timeout pays this in full.
87const LEASE_RELEASE_MAX_WAIT: Duration = Duration::from_secs(30);
88
89/// Consecutive review rounds with no tree progress (see
90/// [`crate::run::ReviewRound::progressed`]) before `review_loop` hands off
91/// instead of spending the rest of the round budget.
92///
93/// Not 1: a single non-progressing round is not yet a pattern — a fixer that
94/// legitimately finds nothing left to change (its previous round's fix already
95/// covered it, and this round's reviewers re-raised only nits) looks the same
96/// as one that is spinning, for exactly one round. Two in a row is where the
97/// two stop being distinguishable, and a review round on this workload has
98/// been measured at 30-45 minutes of reviewer-plus-fixer agent time, so a
99/// third attempt at a tree that has not moved twice running is pure cost.
100/// This does not touch `review_rounds` itself, which stays the operator's
101/// call.
102pub(crate) const STAGNANT_LIMIT: usize = 2;
103
104/// How many times [`Runner::sync_to_base`] will re-land the winner's tree on
105/// a base that moved before giving up and leaving the run `Blocked` for a
106/// person.
107///
108/// Mirrors `land::Step::Rebase`'s budget and the reasoning behind it: a base
109/// that keeps moving faster than a run can catch it is not something more
110/// rebasing fixes, it is a person's call. Not the same *number as*
111/// `land_rounds` - this budget is spent before a pull request exists, land's
112/// after - but bounded for the identical reason, so it uses the same
113/// default. Counted across both call sites in [`Runner::finish_after_tally`]
114/// (once before review, once before the gate), because either one finding
115/// the base still moving is the same signal.
116const BASE_SYNC_ROUNDS: usize = 4;
117
118/// How many times [`Runner::continue_fix_report`] will resume the fixer's own
119/// seat when its CLI turn ended cleanly — usable, non-empty, exit 0 — but the
120/// reply held no [`FixReport`].
121///
122/// The shape this recovers: run 20260912-114326-d3b8's fix-2 came back
123/// `subtype=success`/`is_error=false`/`stop_reason=end_turn` with the reply
124/// "I'll pause here until the `cargo make check` background run reports
125/// back." — a CLI turn that ended cleanly while the fixer's own job had not.
126/// No `FixReport` was ever collected from that seat, and the run moved on to
127/// the next review round regardless.
128///
129/// Bounded independently of `review_rounds` and `graph.retries`: this
130/// recovers one seat's missing report mid-round, not a new round of review or
131/// an ordinary parse retry, and must not itself become the unbounded wait the
132/// rest of this module exists to avoid.
133const MAX_FIX_CONTINUATIONS: usize = 2;
134
135/// One queued agent invocation.
136///
137/// `Clone` so a node can keep the jobs it sent and re-send one: a seat whose
138/// CLI hung up on its own stream is asked again from the same job rather than
139/// rebuilt from scratch. See [`Runner::resume_undelivered`].
140#[derive(Clone)]
141struct SeatJob {
142    spec: AgentSpec,
143    seat: SeatState,
144    cwd: PathBuf,
145    prompt: String,
146    timeout: Duration,
147    allow_write: bool,
148    sessions: bool,
149    artifacts: PathBuf,
150    stem: String,
151    /// The prompt for a seat that has been handed to another roster agent
152    /// (a fresh session): everything the original seat would have
153    /// remembered. `None` when `prompt` already carries it, as the first
154    /// ranking and the implement prompt do. Never a resume-style prompt.
155    handover: Option<String>,
156}
157
158/// How the graph reads one agent invocation.
159///
160/// Quota is split out from an ordinary failure on purpose: a rate-limited call
161/// is known to fail again if retried now, so the retry loop must not spend an
162/// attempt on it. `Dropped` is split out for the opposite reason: unlike
163/// `Failed`, it is worth re-asking, and unlike `Ok`, its text is the CLI's raw
164/// error JSON, never the agent's answer — a caller that matched only
165/// `Ok`/`Quota`/`Failed` before `Dropped` existed must be updated rather than
166/// left to read that JSON as if it were usable output. `resume_undelivered`
167/// is the only caller that acts on it; everywhere else it is reported like an
168/// ordinary failure.
169enum AgentOutcome {
170    /// A usable output.
171    Ok(AgentOutput),
172    /// The CLI ran out of quota / rate limit. Retrying now is pointless.
173    Quota(AgentOutput),
174    /// The CLI hung up on its own stream after billed work. See
175    /// [`agent::AgentOutput::work_undelivered`].
176    Dropped(AgentOutput),
177    /// Any other failure: a timeout, a bad exit code, an empty reply.
178    Failed(String),
179}
180
181/// A request to park the run at its next node boundary.
182///
183/// Cloning is how the request travels: the loop keeps one handle and hands a
184/// clone to each [`Runner`], and every clone points at the same flag. There
185/// is no channel because there is nothing to send - the only message is
186/// "park", it is idempotent, and a flag cannot be missed by a receiver that
187/// was not listening yet.
188///
189/// The boundary is what makes this cheap. Every node writes the run's state
190/// before the next one starts, and every node skips what is already recorded:
191/// `prep` returns early once candidates exist, `implement` asks only the seats
192/// with nothing on disk, `judge` returns early once judgements exist. So a
193/// parked run resumes into exactly the node it stopped before, and no agent
194/// work is thrown away. Killing the process mid-node, by contrast, loses
195/// whatever the seats in flight had not yet written - which for an implement
196/// wave is an hour of paid work.
197///
198/// A [`Runner`] watches two independent handles of this type - see
199/// [`Runner::on_pause`] and [`Runner::watch_interrupt`] - never one shared
200/// between them. `magi serve`'s own shutdown (`Stop::park`) hands out one
201/// clone covering the whole daemon's lifetime and is never asked to un-park,
202/// which is correct exactly because nothing is dispatched after it fires.
203/// `magi serve`'s interrupt scheduler needs the opposite lifetime - a run
204/// that parks for an interrupted task must go on to run other tasks
205/// afterward - so it mints a fresh, unshared [`Pause`] per run instead of
206/// reusing the daemon-wide one.
207#[derive(Debug, Clone, Default)]
208pub struct Pause(Arc<AtomicBool>, Arc<Mutex<Option<String>>>);
209
210impl Pause {
211    /// A pause nobody has asked for yet.
212    #[must_use]
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Ask the run to park at its next node boundary. Idempotent.
218    pub fn park(&self) {
219        self.0.store(true, Ordering::SeqCst);
220    }
221
222    /// Same as [`Pause::park`], but records why, for [`Runner::park_here`] to
223    /// fold into the run's own `park` event - so an operator reading the run
224    /// later knows this was a deliberate interrupt rather than a shutdown or
225    /// a binary swap. The first reason recorded wins; a park already in
226    /// flight is not relabelled by a second, unrelated request.
227    pub fn park_because(&self, reason: impl Into<String>) {
228        let mut reason_guard = self
229            .1
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        if reason_guard.is_none() {
233            *reason_guard = Some(reason.into());
234        }
235        drop(reason_guard);
236        self.park();
237    }
238
239    /// Has a park been asked for?
240    #[must_use]
241    pub fn parked(&self) -> bool {
242        self.0.load(Ordering::SeqCst)
243    }
244
245    /// Why the park was asked for, when the caller used [`Pause::park_because`].
246    #[must_use]
247    pub fn reason(&self) -> Option<String> {
248        self.1
249            .lock()
250            .unwrap_or_else(std::sync::PoisonError::into_inner)
251            .clone()
252    }
253}
254
255/// Drives one run.
256pub struct Runner {
257    /// Run state; public so the CLI can report on it.
258    pub state: RunState,
259    roles: ResolvedRoles,
260    sem: Arc<Semaphore>,
261    /// Set when the daemon's own shutdown (Ctrl-C, a binary swap) wants the
262    /// run parked at its next node boundary. See [`Pause`]'s own doc for why
263    /// this is never the same handle as `interrupt`.
264    pause: Pause,
265    /// Set when `magi serve`'s interrupt scheduler wants this specific run
266    /// parked at its next node boundary, to let a task marked
267    /// [`crate::queue::Task::interrupt`] run alone before this one carries
268    /// on. Unlike `pause`, a fresh, unshared handle per run - see
269    /// [`Runner::watch_interrupt`].
270    interrupt: Pause,
271}
272
273/// Where the branch's own commits start: its merge base with the base branch
274/// as the remote has it now, else with the recorded `base_commit`. A branch
275/// rebased onto a base that moved past `base_commit` would otherwise count
276/// the base's commits as its own under `base_commit..branch`.
277async fn review_base(
278    repo: &Path,
279    remote: &str,
280    base_branch: &str,
281    base_commit: &str,
282    branch: &str,
283) -> String {
284    review_base_checked(repo, remote, base_branch, base_commit, branch)
285        .await
286        .0
287}
288
289/// [`review_base`] plus whether the base was read from a freshly fetched
290/// tracking ref. A failed fetch still uses whatever tracking ref exists (it is
291/// never older than `base_commit`'s view of the base), but the answer is then
292/// not trusted to rewrite a pull request's title.
293async fn review_base_checked(
294    repo: &Path,
295    remote: &str,
296    base_branch: &str,
297    base_commit: &str,
298    branch: &str,
299) -> (String, bool) {
300    let tracking = format!("{remote}/{base_branch}");
301    let fresh = matches!(git::fetch(repo, remote, base_branch).await, Ok(o) if o.ok());
302    if git::rev_exists(repo, &tracking).await
303        && let Ok(mb) = git::merge_base(repo, &tracking, branch).await
304        && !mb.is_empty()
305    {
306        return (mb, fresh);
307    }
308    let mb = git::merge_base(repo, base_commit, branch)
309        .await
310        .ok()
311        .filter(|mb| !mb.is_empty())
312        .unwrap_or_else(|| base_commit.to_owned());
313    (mb, false)
314}
315
316/// Recompute `reviewed_commits` from the branch's own commits. Left as it was
317/// when git cannot say or finds nothing: a stale list is better than a wrong
318/// or empty one.
319pub(crate) async fn refresh_reviewed_commits(state: &mut RunState, branch: &str) {
320    if !is_review_run(state) {
321        return;
322    }
323    let base = review_base(
324        &state.repo,
325        &state.config.merge.remote,
326        &state.base_branch,
327        &state.base_commit,
328        branch,
329    )
330    .await;
331    if let Ok(subjects) = git::subjects(&state.repo, &base, branch).await
332        && !subjects.is_empty()
333        && state.reviewed_commits.as_ref() != Some(&subjects)
334    {
335        state.reviewed_commits = Some(subjects);
336        state.save().ok();
337    }
338}
339
340/// Subjects of the base's commits between the recorded start and the branch's
341/// merge base: what a stale `base_commit..branch` would have mistaken for the
342/// branch's own work.
343async fn leaked_subjects(state: &RunState, branch: &str) -> Option<Vec<String>> {
344    let (base, trusted) = review_base_checked(
345        &state.repo,
346        &state.config.merge.remote,
347        &state.base_branch,
348        &state.base_commit,
349        branch,
350    )
351    .await;
352    if !trusted {
353        return None;
354    }
355    git::subjects(&state.repo, &state.base_commit, &base)
356        .await
357        .ok()
358}
359
360/// May an adopted pull request's title be replaced with `computed`? Only when
361/// it is empty, magi's own shape, or a base commit's subject that leaked in;
362/// a title a person wrote stays. Never when `computed` is itself a leak.
363fn should_retitle(current: &str, computed: &str, leaked: &[String]) -> bool {
364    let is_leak = |t: &str| leaked.iter().any(|l| l.trim() == t.trim());
365    if is_leak(computed) {
366        return false;
367    }
368    let cur = current.trim();
369    cur.is_empty()
370        || cur.starts_with(REVIEW_PROMPT_OPENING)
371        || cur.starts_with("chore: land candidate")
372        || cur.starts_with("magi: candidate")
373        || is_leak(cur)
374}
375
376/// The commit a run branches from: the base branch as the remote has it.
377///
378/// Two failures this replaces. A run used to branch off `HEAD` and so refused
379/// to start on a dirty tree, which made `magi serve` decline every task for as
380/// long as the operator had work in progress - most of the time. Branching off
381/// the *local* base branch fixed that and introduced a worse one: `land` merges
382/// the winner on GitHub, nothing updates the local ref, and the next run
383/// branches off a base missing everything the previous runs landed. Two tasks
384/// in a row from a phone would have had the second silently re-implementing
385/// against stale code and opening a pull request that reverted the first.
386///
387/// Only refs move here - no checkout, no local branch, no merge - so it is safe
388/// with uncommitted work in the tree. A machine with no network still starts:
389/// the fetch may fail and the local tip is used with a warning, because
390/// refusing to run offline is a worse failure than running against a base the
391/// operator can see for themselves.
392///
393/// One function, called by both entry points. Two answers to "where does a run
394/// branch from" is the kind of drift nobody notices until a diff is wrong.
395/// Bring the local `branch` in line with `<remote>/<branch>` before a review
396/// checks it out.
397///
398/// `git worktree add <branch>` resolves the *local* ref, and a branch pushed by
399/// anything other than plain `git push` from this checkout (a jj colocated
400/// workspace, another clone) moves only the remote-tracking ref - so the local
401/// one can be a stale placeholder. It moves only when local is behind the remote or is an
402/// empty placeholder that diverged from it; unpushed local work is kept, and a real
403/// divergence is refused rather than guessed at.
404async fn sync_review_branch(repo: &Path, branch: &str, remote: &str, base: &str) -> Result<()> {
405    let tracking = format!("{remote}/{branch}");
406    let fetched = git::fetch(repo, remote, branch).await;
407    let fresh = matches!(&fetched, Ok(o) if o.ok()) && git::rev_exists(repo, &tracking).await;
408    let local_exists = git::branch_exists(repo, branch).await?;
409    if !fresh {
410        if !local_exists {
411            bail!("no branch `{branch}` in {} or on {remote}", repo.display());
412        }
413        tracing::warn!(
414            "could not read {tracking}; reviewing the local `{branch}`, which may be stale"
415        );
416        return Ok(());
417    }
418    let remote_sha = git::rev_parse(repo, &tracking).await?;
419    if !local_exists {
420        git::git(repo, &["branch", branch, &tracking]).await?;
421        return Ok(());
422    }
423    let local_sha = git::rev_parse(repo, &format!("refs/heads/{branch}")).await?;
424    if local_sha == remote_sha || git::is_ancestor(repo, &remote_sha, &local_sha).await {
425        return Ok(());
426    }
427    if !git::is_ancestor(repo, &local_sha, &remote_sha).await {
428        // Diverged. `reconcile` settles it only when it can prove nothing is
429        // lost: a local tip that is the remote's change rebased is pushed over
430        // it (lease pinned to the tip read here), a tip whose every extra
431        // commit is empty is a placeholder the remote's work replaced, and
432        // anything else is two different changes - a question for a person.
433        match crate::reconcile::reconcile(repo, remote, branch, &local_sha, &remote_sha, base)
434            .await?
435        {
436            crate::reconcile::Reconciliation::Pushed => {
437                tracing::warn!(
438                    "local `{branch}` ({}) is {tracking} ({}) rebased; pushed it over",
439                    short(&local_sha),
440                    short(&remote_sha)
441                );
442                return Ok(());
443            }
444            crate::reconcile::Reconciliation::Placeholder => {}
445            crate::reconcile::Reconciliation::Genuine(d) => return Err((*d).into()),
446        }
447    }
448    let out = git::git_raw(repo, &["branch", "-f", branch, &tracking]).await?;
449    if !out.ok() {
450        bail!(
451            "local `{branch}` ({}) is stale against {tracking} ({}) but git will not move it: {}",
452            short(&local_sha),
453            short(&remote_sha),
454            out.stderr
455        );
456    }
457    tracing::warn!(
458        "local `{branch}` was stale: fast-forwarded {} -> {}",
459        short(&local_sha),
460        short(&remote_sha)
461    );
462    Ok(())
463}
464
465async fn resolve_base(repo: &Path, base_branch: &str, remote: &str) -> Result<String> {
466    let tracking = format!("{remote}/{base_branch}");
467    let fetched = git::fetch(repo, remote, base_branch).await;
468    if let Ok(out) = &fetched
469        && out.ok()
470        && git::rev_exists(repo, &tracking).await
471    {
472        return git::rev_parse(repo, &tracking).await;
473    }
474    let why = match &fetched {
475        Ok(out) if !out.ok() => out.stderr.lines().next().unwrap_or("").to_owned(),
476        Ok(_) => format!("{remote} has no {base_branch}"),
477        Err(e) => e.to_string(),
478    };
479    // No fallback to the local branch: it may be behind, and branching off an
480    // old commit is the stale-checkout bug this check exists to prevent.
481    bail!(
482        "cannot read {tracking} ({why}); refusing to branch off the local \
483         `{base_branch}`, which may be behind. Fix the remote, or set [merge] \
484         base / remote in magi.toml"
485    )
486}
487
488/// Exclusive claim on one run's `magi fix` step, released on drop — including
489/// on an early return or a panic.
490///
491/// `daemon::is_working_on` only sees a heartbeat-publishing daemon; two
492/// manual `magi fix` invocations against the same run are otherwise
493/// invisible to each other and would race to remove and recreate the same
494/// worktree (see [`Runner::fix_selected`]). The lock file itself is the same
495/// `create_new` shape as `queue::Claim`, but unlike a queued task's lock —
496/// which is only ever reclaimed later, out of band, by
497/// `daemon::sweep_stale_claims` running inside `magi serve`/`magi web` — a
498/// `magi fix` invocation is not necessarily running under either of those, so
499/// nothing would ever sweep a lock a killed or crashed process left behind.
500/// [`Self::acquire`] therefore reclaims a stale lock itself, on the same
501/// conservative PID-liveness policy `sweep_stale_claims` and `cache`'s own
502/// lease use: an unreadable or unparsable pid, or a liveness query the
503/// platform cannot answer, reads as alive and the lock is left in place.
504struct FixClaim {
505    path: PathBuf,
506}
507
508impl FixClaim {
509    fn acquire(dir: &Path) -> Result<Self> {
510        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
511        let path = dir.join("fix.lock");
512        match Self::create(&path) {
513            Ok(claim) => Ok(claim),
514            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
515                if Self::reclaim_if_dead(&path) {
516                    Self::create(&path).with_context(|| format!("lock {}", path.display()))
517                } else {
518                    bail!(
519                        "another `magi fix` is already running for this run ({} exists)",
520                        path.display()
521                    )
522                }
523            }
524            Err(e) => Err(e).with_context(|| format!("lock {}", path.display())),
525        }
526    }
527
528    fn create(path: &Path) -> std::io::Result<Self> {
529        let mut f = std::fs::OpenOptions::new()
530            .write(true)
531            .create_new(true)
532            .open(path)?;
533        use std::io::Write as _;
534        // Read back by `reclaim_if_dead` on a later, stuck invocation.
535        writeln!(f, "{}", std::process::id())?;
536        Ok(Self {
537            path: path.to_owned(),
538        })
539    }
540
541    /// True if the lock named a process confirmed dead, in which case it was
542    /// also removed. Never true on an unreadable file, an unparsable pid, or
543    /// a liveness query the platform cannot answer — see this type's own doc.
544    fn reclaim_if_dead(path: &Path) -> bool {
545        let dead = std::fs::read_to_string(path)
546            .ok()
547            .and_then(|body| body.trim().parse::<u32>().ok())
548            .is_some_and(|pid| !crate::proc::pid_alive(pid));
549        dead && std::fs::remove_file(path).is_ok()
550    }
551}
552
553impl Drop for FixClaim {
554    fn drop(&mut self) {
555        let _ = std::fs::remove_file(&self.path);
556    }
557}
558
559impl Runner {
560    /// Start a fresh run against `repo`.
561    pub async fn start(
562        repo: &Path,
563        instruction: String,
564        config: Config,
565        origin: Origin,
566    ) -> Result<Self> {
567        Self::start_naming(repo, instruction, "", config, origin).await
568    }
569
570    /// [`Runner::start`] for a queued task: `also_scan` (the task's title) is
571    /// searched for branch and commit references along with the instruction,
572    /// since a task may name the work it is about only in its title.
573    pub async fn start_naming(
574        repo: &Path,
575        instruction: String,
576        also_scan: &str,
577        config: Config,
578        origin: Origin,
579    ) -> Result<Self> {
580        let repo = git::toplevel(repo).await?;
581        let missing = agent::missing_programs(&config.agents);
582        if !missing.is_empty() {
583            bail!(
584                "these agent programs are not on PATH: {}. Fix the roster in \
585                 magi.toml or install them.",
586                missing.join(", ")
587            );
588        }
589        let base_branch =
590            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
591                .await?;
592        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
593        let roles = config.resolve_roles()?;
594        let max_parallel = config.graph.max_parallel.max(1);
595        // A task that points at work already in the repository starts from
596        // it; what the repository says about each reference is recorded.
597        let seeds = refs::resolve(
598            &repo,
599            &base_commit,
600            &config.merge.remote,
601            &format!("{also_scan}\n{instruction}"),
602        )
603        .await;
604        refs::plan(&repo, &seeds).await?;
605        let mut state = RunState::new(repo, base_branch, base_commit, instruction, config);
606        // Recorded before the first save, so a crash right after minting
607        // cannot leave a run with no origin. Legibility only: nothing reads it
608        // to decide anything.
609        state.origin = Some(origin);
610        for seed in &seeds {
611            state.event(
612                "seed",
613                refs::describe(std::slice::from_ref(seed)).unwrap_or_default(),
614            );
615        }
616        state.seeds = seeds;
617        state.event("start", format!("run {} created", state.id));
618        state.save()?;
619        Ok(Self {
620            state,
621            roles,
622            sem: Arc::new(Semaphore::new(max_parallel)),
623            pause: Pause::new(),
624            interrupt: Pause::new(),
625        })
626    }
627
628    /// Open a review-only run against work that already exists on `branch`.
629    ///
630    /// The expensive half of the graph is the implement wave — measured at
631    /// 111 and 134 internal tool-loop turns on this repository, against a
632    /// handful for a judge or a reviewer. The cheap half is worth running on
633    /// hand-written work too, and there was no way to reach it.
634    ///
635    /// No new state and no schema change are needed: a run with **one** viable
636    /// candidate and a tally already decided degrades `execute` to exactly
637    /// review → gate → merge, because `judge` skips a single-candidate field,
638    /// `deliberate` has fewer than two first choices to reconcile, `vote`
639    /// returns early, `tally` is already present and `fold_losers` has no
640    /// losers. Resuming such a run therefore does the right thing as well.
641    pub async fn review(repo: &Path, branch: &str, config: Config, origin: Origin) -> Result<Self> {
642        Self::review_taking_over(repo, branch, config, None, origin).await
643    }
644
645    /// [`Runner::review`] for a queued task's retry: when an earlier attempt
646    /// at the same task still has `branch` checked out, its worktree is
647    /// released first if that is safe (see [`crate::handover`]), and the
648    /// review refuses with the reason if it is not. `None` is a hand-run
649    /// review: it has no earlier attempts, so only a worktree of a dead run
650    /// magi recorded itself can be released.
651    pub async fn review_taking_over(
652        repo: &Path,
653        branch: &str,
654        config: Config,
655        takeover: Option<crate::handover::Takeover>,
656        origin: Origin,
657    ) -> Result<Self> {
658        let repo = git::toplevel(repo).await?;
659        let missing = agent::missing_programs(&config.agents);
660        if !missing.is_empty() {
661            bail!(
662                "these agent programs are not on PATH: {}. Fix the roster in \
663                 magi.toml or install them.",
664                missing.join(", ")
665            );
666        }
667        let base_branch =
668            git::merge_base_branch(&repo, &config.merge.remote, config.merge.base.as_deref())
669                .await?;
670        if base_branch == branch {
671            bail!("`{branch}` is the base branch; there is nothing to review against");
672        }
673        let base_commit = resolve_base(&repo, &base_branch, &config.merge.remote).await?;
674
675        let roles = config.resolve_roles()?;
676        let max_parallel = config.graph.max_parallel.max(1);
677        let mut state = RunState::new(
678            repo.clone(),
679            base_branch,
680            base_commit.clone(),
681            String::new(),
682            config,
683        );
684        state.origin = Some(origin);
685
686        // Released before anything else touches the branch: a stale local
687        // branch is moved with `git branch -f`, which git refuses while an
688        // earlier attempt's worktree still has it checked out. Everything
689        // after this point that can fail puts the old run back.
690        // A hand-run review has no task, hence no earlier attempts, but a
691        // worktree of a dead run magi made may still be released.
692        let takeover = takeover.unwrap_or_else(|| crate::handover::Takeover {
693            earlier: Vec::new(),
694            home: crate::run::home(),
695            choice: None,
696        });
697        let released = crate::handover::release(&repo, branch, &state.id, &takeover).await?;
698        if let Some(released) = &released {
699            state.event(
700                "release",
701                format!(
702                    "took `{branch}` over from run {}: its worktree was released: {}",
703                    crate::run::short_of(&released.old_id),
704                    released.audit
705                ),
706            );
707        }
708        // The owner's answer to an earlier divergence question is applied
709        // here: after the release (git will not move a checked-out branch)
710        // and before the sync that would otherwise ask again.
711        if let Some(choice) = takeover.choice.as_ref()
712            && let Err(e) =
713                crate::reconcile::apply_choice(&repo, &state.config.merge.remote, branch, choice)
714                    .await
715        {
716            if let Some(released) = &released {
717                released.restore(&repo, branch).await;
718            }
719            return Err(e.context("applying the owner's answer about the diverged branch"));
720        }
721        let opened =
722            Self::open_review(&repo, branch, state, roles, max_parallel, base_commit).await;
723        if opened.is_err()
724            && let Some(released) = &released
725        {
726            released.restore(&repo, branch).await;
727        }
728        opened
729    }
730
731    /// The half of [`Runner::review_taking_over`] that can fail after an
732    /// earlier attempt's worktree was released.
733    async fn open_review(
734        repo: &Path,
735        branch: &str,
736        mut state: RunState,
737        roles: ResolvedRoles,
738        max_parallel: usize,
739        base_commit: String,
740    ) -> Result<Self> {
741        sync_review_branch(repo, branch, &state.config.merge.remote, &base_commit).await?;
742        // The commit subjects are the closest thing to a task statement that
743        // existing work carries, and the reviewers are told as much.
744        let start = review_base(
745            repo,
746            &state.config.merge.remote,
747            &state.base_branch,
748            &base_commit,
749            branch,
750        )
751        .await;
752        let log = git::log_oneline(repo, &start, branch)
753            .await
754            .unwrap_or_default();
755        let instruction = format!(
756            "Review the work already on branch `{branch}`. There is no task \
757             statement: what the change claims to do is whatever its commits \
758             say.\n\n{}",
759            if log.trim().is_empty() {
760                "(no commit messages)"
761            } else {
762                log.trim()
763            }
764        );
765        state.instruction = instruction;
766        state.reviewed_commits = Some(
767            git::subjects(repo, &start, branch)
768                .await
769                .unwrap_or_default(),
770        );
771
772        // An attached worktree, so the fixer's commits land on the branch under
773        // review rather than on a detached head nobody will look at again.
774        let worktree = state.worktree_root().join("under-review");
775        if let Some(parent) = worktree.parent() {
776            tokio::fs::create_dir_all(parent).await.ok();
777        }
778        let path = worktree.to_string_lossy().to_string();
779        git::git(repo, &["worktree", "add", &path, branch])
780            .await
781            .with_context(|| {
782                format!("checking out `{branch}` at {path} (is it checked out elsewhere?)")
783            })?;
784
785        let setup = match ensure_setup_config(&mut state, repo).await {
786            Ok(()) => worktree_setup::prepare(&state.config, repo, &worktree).await,
787            Err(e) => Err(e),
788        };
789        if let Err(e) = setup {
790            git::worktree_remove(repo, &worktree).await.ok();
791            return Err(e);
792        }
793        let commits = git::commits_ahead(&worktree, &base_commit, "HEAD")
794            .await
795            .unwrap_or(0);
796        if commits == 0 {
797            git::worktree_remove(repo, &worktree).await.ok();
798            bail!("`{branch}` has no commits beyond {}", short(&base_commit));
799        }
800        let files = git::changed_files(&worktree, &base_commit, "HEAD")
801            .await
802            .map(|f| f.len())
803            .unwrap_or(0);
804        if files == 0
805            && let (Ok(head_tree), Ok(base_tree)) = (
806                git::tree_of(&worktree, "HEAD").await,
807                git::tree_of(&worktree, &base_commit).await,
808            )
809            && head_tree == base_tree
810        {
811            let head = git::rev_parse(&worktree, "HEAD").await.unwrap_or_default();
812            git::worktree_remove(repo, &worktree).await.ok();
813            bail!(
814                "`{branch}` at {} has a tree identical to base {}; this usually means \
815                 the branch ref is stale (check `git rev-parse refs/heads/{branch}` \
816                 against `{}/{branch}`) rather than an empty change",
817                short(&head),
818                short(&base_commit),
819                state.config.merge.remote
820            );
821        }
822        let stat = git::diff_stat(&worktree, &base_commit, "HEAD")
823            .await
824            .unwrap_or_default();
825
826        state.candidates.push(Candidate {
827            index: 0,
828            label: 'A',
829            // Not an agent id on purpose: nothing in the roster wrote this, and
830            // the stats tables must not credit anyone with a win for it.
831            agent: EXISTING_BRANCH.to_owned(),
832            branch: branch.to_owned(),
833            worktree,
834            summary: String::new(),
835            stat,
836            files,
837            commits,
838            empty: false,
839            failed: None,
840            verified_noop: None,
841            duration_ms: 0,
842            folded: false,
843        });
844        state.tally = Some(Tally {
845            first_choice: BTreeMap::from([('A', 0)]),
846            borda: BTreeMap::new(),
847            winner: 'A',
848            rankings: 0,
849            unanimous_initial: false,
850            deliberated: false,
851            changed_votes: 0,
852            unanimous_final: false,
853            tie_break: None,
854            // No panel sat, so no quorum applies. Zero judges is the correct
855            // number for work that never competed, and must not be reported as
856            // a collapsed panel.
857            judges: 0,
858            present: 0,
859            quorum: 0,
860            met_quorum: true,
861            uncontested: Some("review-only run: nothing competed".to_owned()),
862        });
863        state.status = RunStatus::Reviewing;
864        state.event(
865            "start",
866            format!(
867                "review-only run {} on `{branch}` ({files} files, {commits} commits)",
868                state.id
869            ),
870        );
871        state.save()?;
872        Ok(Self {
873            state,
874            roles,
875            sem: Arc::new(Semaphore::new(max_parallel)),
876            pause: Pause::new(),
877            interrupt: Pause::new(),
878        })
879    }
880
881    /// Reopen an existing run.
882    pub fn resume(id: &str) -> Result<Self> {
883        let state = RunState::load(id)?;
884        if let Some(to) = &state.released_to {
885            bail!(
886                "run {} cannot be resumed: its worktree was released to run {}",
887                state.short(),
888                crate::run::short_of(to)
889            );
890        }
891        let roles = state.config.resolve_roles()?;
892        let max_parallel = state.config.graph.max_parallel.max(1);
893        Ok(Self {
894            state,
895            roles,
896            sem: Arc::new(Semaphore::new(max_parallel)),
897            pause: Pause::new(),
898            interrupt: Pause::new(),
899        })
900    }
901
902    /// Walk the graph to a terminal state, skipping nodes already recorded.
903    ///
904    /// Every way a run is driven - the queue loop, `magi run`, a resume from
905    /// the phone - ends here, so this is the one place a run that ended
906    /// Blocked / Stalled / Failed, or died with an error, is announced to the
907    /// notification centre. Best-effort: see [`crate::notices::raise`].
908    pub async fn execute(&mut self) -> Result<()> {
909        let result = self.execute_graph().await;
910        self.mark_driver_exited();
911        let ended = if result.is_err() {
912            Some(crate::notices::run_stopped(&self.state.id, &self.state))
913        } else {
914            crate::notices::run_ended(&self.state)
915        };
916        if let Some(notice) = ended {
917            crate::notices::raise(notice);
918        }
919        result
920    }
921
922    /// Record that this process no longer drives the run, so its pid (a
923    /// daemon's outlives the run) is not read as a live driver.
924    ///
925    /// Written onto the record as it is on disk, never this copy: another
926    /// process may have resumed the run (recording its own pid and clearing
927    /// the flag) or released its worktree since this copy was read, and
928    /// saving over that would mark a running driver dead. Only a record still
929    /// naming this process as the driver is touched.
930    fn mark_driver_exited(&mut self) {
931        self.state.driver_exited = true;
932        let pid = std::process::id();
933        let Ok(mut disk) = RunState::load(&self.state.id) else {
934            return;
935        };
936        if disk.released_to.is_some() || disk.driver_pid != Some(pid) || disk.driver_exited {
937            return;
938        }
939        disk.driver_exited = true;
940        if let Err(e) = disk.save() {
941            tracing::warn!("could not record that run {} stopped: {e:#}", self.state.id);
942        }
943    }
944
945    async fn execute_graph(&mut self) -> Result<()> {
946        // Moving again, so it is no longer parked. Set before the walk rather
947        // than in `resume`, so every way of re-entering the graph clears it
948        // and a card cannot claim a run is waiting to be resumed while the
949        // agents are already working.
950        self.state.parked = false;
951        // Any seat this state still lists as answering belongs to whatever
952        // process last drove this run — this one included, if it crashed
953        // mid-wave. Cleared and flushed immediately, before anything else
954        // runs, so a resume can never show a seat as live when nothing is
955        // asking it anything yet; the node that actually dispatches the next
956        // wave repopulates it.
957        self.state.clear_active();
958        // Recorded in the same spot, and flushed together with the clear
959        // above: this is the pid a reader checks (`RunState::liveness`) when
960        // no daemon claim exists to answer "is a process still driving this
961        // run" — a plain `magi run` / `magi review` typed into a terminal
962        // claims nothing there. Always overwritten, never only-if-absent, so
963        // a resumed run's stale pid from a previous, possibly-dead process
964        // can never survive into this one's own report. Unlike
965        // `clear_active`, this changes on every single `execute()` call, so
966        // the save below is now unconditional rather than only-if-cleared.
967        //
968        // `driver_started_at` is recorded in the same breath, from this same
969        // pid, so `liveness` can tell a live pid that is genuinely still us
970        // apart from one the OS has since handed to an unrelated process —
971        // see that field's own doc for why the pid alone is not enough.
972        // A resume that raced a takeover: the record on disk says the worktree
973        // was handed to a later run after this copy was read. Saving over it
974        // would erase that and drive a run with nothing to run in.
975        if let Ok(disk) = RunState::load(&self.state.id)
976            && let Some(to) = &disk.released_to
977        {
978            bail!(
979                "run {} cannot continue: its worktree was released to run {}",
980                self.state.short(),
981                crate::run::short_of(to)
982            );
983        }
984        let pid = std::process::id();
985        self.state.driver_pid = Some(pid);
986        self.state.driver_started_at = crate::proc::process_started_at(pid);
987        self.state.driver_exited = false;
988        self.state.save()?;
989        // A run that already lost its quorum never resumes into the verdict
990        // machinery: `deliberate` and `vote` would otherwise clobber the
991        // stalled marker back to Voting and the run would keep going past a
992        // verdict that is no longer trustworthy. Everything already recorded is
993        // kept, so the run stays resumable (or foldable) for a human to pick up.
994        //
995        // On --resume the run gets one chance to repair itself: the seats a
996        // rate limit took out are re-asked. If their quota has since reset and
997        // the quorum is restored, the run picks up and finishes; otherwise it
998        // stays stale and still-resumable for a later retry. If it does not
999        // recover, the returned status stays `Stalled` and nothing was
1000        // clobbered (the recovery only mutates entries for the lost seats).
1001        if self.state.status == RunStatus::Stalled {
1002            if self.recover_stall().await? {
1003                self.finish_after_tally().await?;
1004            } else {
1005                // Still below quorum: persist the marker and stay resumable.
1006                self.state.save()?;
1007            }
1008            return Ok(());
1009        }
1010        // A run parked inside `land` - watching CI, mid fix-round, or
1011        // waiting on the owner's merge approval - resumes directly into it,
1012        // never back through `prep`. Everything before `merge` already
1013        // concluded; that is the only way `status` reaches `Landing` in the
1014        // first place. Re-walking `review_loop` first would also be actively
1015        // wrong: its own status recomputation (see its doc) treats any
1016        // clean round as reason to set `status` to `Gating`, which would
1017        // clobber this marker before `merge` ever ran, and this run would
1018        // never find its way back into `land` at all.
1019        if self.state.status == RunStatus::Landing {
1020            self.run_land().await?;
1021            // `run_land` may have settled the run right here - CI came back
1022            // green and the PR merged, say - without ever passing back
1023            // through `merge`'s own trailing call. Whatever it left `status`
1024            // as is what this has to read.
1025            self.settle_questions();
1026            return Ok(());
1027        }
1028        // A run parked at `merge` on the owner's word about a withheld PR text
1029        // resumes straight into `merge`. Re-walking the tail would sync to the
1030        // base again (possibly rebasing, possibly spending the fixer) after the
1031        // gate had passed, and then push a tree nobody reviewed or gated.
1032        if self.state.github_text.is_some()
1033            && self.state.merge.is_none()
1034            && self.state.status == RunStatus::Gating
1035        {
1036            self.merge().await?;
1037            self.state.save()?;
1038            return Ok(());
1039        }
1040        self.prep().await?;
1041        if self.park_here()? {
1042            return Ok(());
1043        }
1044        self.advise().await?;
1045        if self.park_here()? {
1046            return Ok(());
1047        }
1048        self.implement().await?;
1049        if self.park_here()? {
1050            return Ok(());
1051        }
1052        // `after_implement` already saved the state and settled any open
1053        // questions when it set this; nothing later in the graph has
1054        // anything to judge.
1055        if self.state.status == RunStatus::VerifiedNoop {
1056            return Ok(());
1057        }
1058        self.judge().await?;
1059        if self.park_here()? {
1060            return Ok(());
1061        }
1062        self.deliberate().await?;
1063        if self.park_here()? {
1064            return Ok(());
1065        }
1066        self.vote().await?;
1067        if self.park_here()? {
1068            return Ok(());
1069        }
1070        self.tally()?;
1071        // A verdict that lost its quorum is not trustworthy: do not review,
1072        // gate, or merge on it. Everything already done is kept, so the run
1073        // stays resumable (or foldable); the human can replace the agent that
1074        // ran out of quota and pick it up.
1075        if self.state.status == RunStatus::Stalled {
1076            // Persist the stalled marker now — the normal end-of-execute save
1077            // below is below this early return, and without it a resumed run
1078            // would reload a pre-tally status and keep going.
1079            self.state.save()?;
1080            return Ok(());
1081        }
1082        self.finish_after_tally().await?;
1083        Ok(())
1084    }
1085
1086    /// Park here if asked to, recording it in the run's own timeline.
1087    ///
1088    /// Returns whether the caller should stop walking the graph. The state is
1089    /// saved either way by the node that just finished; this adds the event so
1090    /// the operator's card says why a run that is neither finished nor moving
1091    /// is sitting where it is.
1092    fn park_here(&mut self) -> Result<bool> {
1093        // Either handle asking is enough - see `Pause`'s own doc for why
1094        // they are never the same one. `interrupt` is checked second so a
1095        // reason it carries is preferred in the message below over a plain
1096        // shutdown park racing it at the same boundary.
1097        if !self.pause.parked() && !self.interrupt.parked() {
1098            return Ok(false);
1099        }
1100        let why = match self.interrupt.reason().or_else(|| self.pause.reason()) {
1101            Some(reason) => format!(
1102                "parked after `{}` ({reason}) — resume to carry on from here",
1103                self.state.status.as_str()
1104            ),
1105            None => format!(
1106                "parked after `{}` — resume to carry on from here",
1107                self.state.status.as_str()
1108            ),
1109        };
1110        self.state.event("park", why);
1111        self.state.parked = true;
1112        self.state.save()?;
1113        Ok(true)
1114    }
1115
1116    /// Hand the runner the pause `magi serve`'s own shutdown watches.
1117    pub fn on_pause(&mut self, pause: Pause) {
1118        self.pause = pause;
1119    }
1120
1121    /// Hand the runner a second, independent pause: `magi serve`'s interrupt
1122    /// scheduler asking this one run - and no other - to park so a task
1123    /// marked [`crate::queue::Task::interrupt`] can run alone. See
1124    /// [`Pause`]'s own doc for why this is never [`Runner::on_pause`]'s
1125    /// handle.
1126    pub fn watch_interrupt(&mut self, pause: Pause) {
1127        self.interrupt = pause;
1128    }
1129
1130    /// Abandon this run's own open questions, once `status` has actually
1131    /// settled rather than merely paused.
1132    ///
1133    /// `Blocked` and `Stalled` are `RunStatus::resumable` — a human can pick
1134    /// either back up with the candidates, the review round and the seat
1135    /// sessions already on disk, so a question an implementer asked mid-round
1136    /// may still get a real answer read by a real resume. Only the statuses
1137    /// `resumable` excludes are actually final: the run merged, it reached
1138    /// `Ready` with nothing left to do, it failed outright with no
1139    /// established point to continue from, or every candidate agreed, with
1140    /// evidence, that nothing belonged in the worktree (`VerifiedNoop`). In
1141    /// every one of those the seat that asked is gone for good, exactly like
1142    /// the run being deleted under `magi run rm` - so the same cleanup
1143    /// applies, worded for what actually happened instead of "the run was
1144    /// deleted".
1145    ///
1146    /// Best-effort and silent on success: called from every place `status`
1147    /// can land on one of those three, including ones a resumed run revisits,
1148    /// so it must cost nothing when there was nothing open to begin with.
1149    fn settle_questions(&mut self) {
1150        if let Err(e) = ask::Questions::open().settle_run(&self.state.id, self.state.status) {
1151            tracing::warn!("abandon questions for {}: {e:#}", self.state.id);
1152        }
1153    }
1154
1155    /// The tail of the graph after a trustworthy tally: fold losers, review,
1156    /// gate, merge, and persist.
1157    async fn finish_after_tally(&mut self) -> Result<()> {
1158        self.fold_losers().await?;
1159        // Before review starts, and again right before the gate: a run's
1160        // review rounds can themselves take long enough for the base to move
1161        // a second time, and the gate is the one node whose "green" gets
1162        // acted on.
1163        self.sync_to_base().await?;
1164        if self.state.status == RunStatus::AlreadyInBase {
1165            return Ok(());
1166        }
1167        self.review_loop().await?;
1168        self.sync_to_base().await?;
1169        if self.state.status == RunStatus::AlreadyInBase {
1170            return Ok(());
1171        }
1172        self.gate().await?;
1173        self.merge().await?;
1174        self.state.save()?;
1175        Ok(())
1176    }
1177
1178    // ---------------------------------------------------------------- prep
1179
1180    /// Run `[worktree] setup` in a seat's fresh worktree. A failure blocks the
1181    /// run with the step and its output in the event; it is never swallowed.
1182    /// During `prep` (`in_prep`) the whole preparation is rolled back, so a
1183    /// resume starts over instead of finding candidates and missing seats.
1184    async fn setup_seat_worktree(&mut self, repo: &Path, wt: &Path, in_prep: bool) -> Result<()> {
1185        let result = match ensure_setup_config(&mut self.state, repo).await {
1186            Ok(()) => worktree_setup::prepare(&self.state.config, repo, wt).await,
1187            Err(e) => Err(e),
1188        };
1189        if let Err(e) = result {
1190            git::worktree_remove(repo, wt).await.ok();
1191            if in_prep {
1192                self.rollback_prep(repo).await;
1193            }
1194            self.state.status = RunStatus::Blocked;
1195            self.state.event(
1196                "setup",
1197                format!("worktree setup failed in {}: {e:#}", wt.display()),
1198            );
1199            self.state.save()?;
1200            return Err(e);
1201        }
1202        Ok(())
1203    }
1204
1205    /// Undo everything `prep` created, so a resume re-runs it whole.
1206    async fn rollback_prep(&mut self, repo: &Path) {
1207        let root = self.state.worktree_root();
1208        for c in std::mem::take(&mut self.state.candidates) {
1209            git::worktree_remove(repo, &c.worktree).await.ok();
1210            git::branch_delete(repo, &c.branch).await.ok();
1211        }
1212        let seats = self.roles.judges.len().max(self.state.config.graph.judges);
1213        for j in 1..=seats {
1214            git::worktree_remove(repo, &root.join(format!("judge-{j}")))
1215                .await
1216                .ok();
1217        }
1218        for k in 1..=self.state.config.graph.advisors {
1219            git::worktree_remove(repo, &root.join(format!("advisor-{k}")))
1220                .await
1221                .ok();
1222        }
1223    }
1224
1225    async fn prep(&mut self) -> Result<()> {
1226        if !self.state.candidates.is_empty() {
1227            return Ok(());
1228        }
1229        self.state.status = RunStatus::Prep;
1230        let repo = self.state.repo.clone();
1231        let base = self.state.base_commit.clone();
1232        let plan = refs::plan(&repo, &self.state.seeds).await?;
1233        let start = plan.start.clone().unwrap_or_else(|| base.clone());
1234        let root = self.state.worktree_root();
1235        let labels = blind::assign_labels(self.roles.implementers.len(), self.state.seed);
1236
1237        // The hook is the write-time half of the blindness contract; the
1238        // presentation filter in `blind` is the half that cannot be bypassed.
1239        let hooks_dir = self.state.dir().join("hooks");
1240        if self.state.config.blind.commit_msg_hook {
1241            std::fs::create_dir_all(&hooks_dir)
1242                .with_context(|| format!("create {}", hooks_dir.display()))?;
1243            let script = blind::commit_msg_hook(&self.state.config.blind.strip_lines);
1244            let path = hooks_dir.join("commit-msg");
1245            std::fs::write(&path, script).with_context(|| format!("write {}", path.display()))?;
1246            make_executable(&path)?;
1247            // Ref-counted rather than a plain idempotent set: with more than
1248            // one run able to be in flight in the same repository at once
1249            // (see `Config::daemon.max_concurrent_runs`), a bare "already
1250            // true?" check cannot tell "another run of mine still needs
1251            // this" from "nobody does", and the run that happens to finish
1252            // first would disable the hook out from under a sibling still
1253            // relying on it.
1254            git::acquire_worktree_config(&repo).await?;
1255            self.state.enabled_worktree_config = true;
1256        }
1257
1258        // `[worktree] setup` hides its products per worktree, which needs the
1259        // same per-worktree config the hook does. Held until fold, like it.
1260        ensure_setup_config(&mut self.state, &repo).await?;
1261
1262        for (index, (spec, label)) in self
1263            .roles
1264            .implementers
1265            .clone()
1266            .into_iter()
1267            .zip(labels)
1268            .enumerate()
1269        {
1270            let branch = self.state.branch_for(label);
1271            let worktree = root.join(format!("cand-{label}"));
1272            git::worktree_add_branch(&repo, &worktree, &branch, &start).await?;
1273            if self.state.config.blind.commit_msg_hook {
1274                git::set_worktree_hooks_path(&worktree, &hooks_dir).await?;
1275            }
1276            git::local_exclude(&worktree, "/.magi/").await?;
1277            if let Err(e) = worktree_setup::prepare(&self.state.config, &repo, &worktree).await {
1278                // Nothing of this prep may survive: a resume must start over,
1279                // not find half the candidates and skip the rest.
1280                git::worktree_remove(&repo, &worktree).await.ok();
1281                git::branch_delete(&repo, &branch).await.ok();
1282                self.rollback_prep(&repo).await;
1283                self.state.status = RunStatus::Blocked;
1284                self.state
1285                    .event("prep", format!("worktree setup failed: {e:#}"));
1286                self.state.save()?;
1287                return Err(e);
1288            }
1289            for pick in &plan.picks {
1290                if let Err(e) = git::cherry_pick(&worktree, pick).await {
1291                    self.state.status = RunStatus::Blocked;
1292                    self.state
1293                        .event("prep", format!("cannot apply referenced commit: {e}"));
1294                    self.state.save()?;
1295                    return Err(e);
1296                }
1297            }
1298            self.state.candidates.push(Candidate {
1299                index,
1300                label,
1301                agent: spec.id.clone(),
1302                branch,
1303                worktree,
1304                summary: String::new(),
1305                stat: String::new(),
1306                files: 0,
1307                commits: 0,
1308                empty: false,
1309                failed: None,
1310                verified_noop: None,
1311                duration_ms: 0,
1312                folded: false,
1313            });
1314        }
1315
1316        for j in 1..=self.roles.judges.len() {
1317            let wt = root.join(format!("judge-{j}"));
1318            if !wt.exists() {
1319                git::worktree_add_detached(&repo, &wt, &base).await?;
1320                self.setup_seat_worktree(&repo, &wt, true).await?;
1321            }
1322        }
1323
1324        // Disposable, detached checkouts for the design-deliberation stage's
1325        // advisor seats — the same shape as the judges' above, at the same
1326        // base commit, since advisors also only ever read. Sized off the
1327        // configured count directly rather than a resolved roster: unlike
1328        // `implementers`/`judges`/`reviewers`, advisor seats are resolved
1329        // lazily inside `advise` itself (see `Config::advisors`'s doc), so
1330        // `prep` has no `ResolvedRoles` field to read a count from here.
1331        if self.state.config.graph.advise {
1332            for k in 1..=self.state.config.graph.advisors {
1333                let wt = root.join(format!("advisor-{k}"));
1334                if !wt.exists() {
1335                    git::worktree_add_detached(&repo, &wt, &base).await?;
1336                    self.setup_seat_worktree(&repo, &wt, true).await?;
1337                }
1338            }
1339        }
1340
1341        // A judge cannot tell it is looking at its own patch — the seats keep
1342        // separate conversations — but a panel that shares agents with the
1343        // field is less independent than it looks, and that is worth saying out
1344        // loud once per run rather than leaving it in the config.
1345        let authors: Vec<&str> = self
1346            .roles
1347            .implementers
1348            .iter()
1349            .map(|a| a.id.as_str())
1350            .collect();
1351        let overlap: Vec<String> = self
1352            .roles
1353            .judges
1354            .iter()
1355            .enumerate()
1356            .filter(|(_, j)| authors.contains(&j.id.as_str()))
1357            .map(|(i, j)| format!("judge {} = {}", i + 1, j.id))
1358            .collect();
1359        if !overlap.is_empty() {
1360            let note = format!(
1361                "{} also authored a candidate; blind, but the panel is less \
1362                 independent than {} distinct agents would be",
1363                overlap.join(", "),
1364                self.roles.judges.len()
1365            );
1366            self.state.event("prep", note);
1367        }
1368
1369        self.state.event(
1370            "prep",
1371            format!(
1372                "{} candidates, {} judges, base {} ({})",
1373                self.state.candidates.len(),
1374                self.roles.judges.len(),
1375                &self.state.base_commit[..7.min(self.state.base_commit.len())],
1376                self.state.base_branch
1377            ),
1378        );
1379        self.state.status = RunStatus::Implementing;
1380        self.state.save()?;
1381        Ok(())
1382    }
1383
1384    // -------------------------------------------------------------- advise
1385
1386    /// The design-deliberation stage: independent, read-only advisor seats
1387    /// each sketch a design before any implementer touches the repository,
1388    /// and (when at least one produced a usable proposal) a synthesis seat
1389    /// blends them into a brief `implement` carries in every candidate's
1390    /// prompt.
1391    ///
1392    /// `[graph] advise` is the on/off switch, on by default; `[graph]
1393    /// advisors` is the proposal count. Everything here is best-effort and
1394    /// non-fatal to the run: a misconfigured `[roles] advisors`, a roster
1395    /// that cannot reach quota, or a synthesis seat that produced nothing
1396    /// usable all leave `implement` exactly as it was before this stage
1397    /// existed — the task instruction alone — rather than failing the whole
1398    /// competition over an enrichment stage. Every outcome is still recorded
1399    /// as an event, so a run that got nothing from this stage says why.
1400    ///
1401    /// [`RunState::advise_attempted`] is this node's idempotency marker, the
1402    /// same role [`RunState::judge_skipped`] plays for `judge`: without it a
1403    /// resumed run whose stage failed would re-run it, and re-spend the
1404    /// agent calls, on every reentry before `implement`.
1405    ///
1406    /// Also skipped once any candidate shows implementation progress — the
1407    /// exact predicate `implement` itself uses to decide a candidate is no
1408    /// longer "todo" (see its own `todo` filter). `advise_attempted` alone
1409    /// is not enough: a run created by an older binary that predates this
1410    /// field deserializes it as `false` (`#[serde(default)]`), so resuming
1411    /// an already-`Implementing`-or-later run under this build would
1412    /// otherwise walk straight back through `prep` (a no-op once candidates
1413    /// exist) into this node and spawn every advisor seat against worktrees
1414    /// `prep` never recreated — after implementation has already started,
1415    /// which is exactly the invariant this stage exists to guarantee.
1416    async fn advise(&mut self) -> Result<()> {
1417        let implement_untouched = self
1418            .state
1419            .candidates
1420            .iter()
1421            .all(|c| c.commits == 0 && c.failed.is_none() && !c.empty);
1422        if !self.state.config.graph.advise || self.state.advise_attempted {
1423            return Ok(());
1424        }
1425        if !implement_untouched {
1426            self.state.event(
1427                "advise",
1428                "skipping the design-deliberation stage: at least one \
1429                 candidate already shows implementation progress, so this \
1430                 run is past the point the stage exists to run before"
1431                    .to_owned(),
1432            );
1433            self.state.advise_attempted = true;
1434            self.state.save()?;
1435            return Ok(());
1436        }
1437        let run_id = self.state.id.clone();
1438        let prompts = self.state.config.prompts.clone();
1439        let instruction = self.state.instruction.clone();
1440        let language = self.state.config.graph.language.clone();
1441        let root = self.state.worktree_root();
1442        let n = self.state.config.graph.advisors;
1443        let where_recorded = self.state.dir().join("run.json");
1444
1445        let seats = match self.state.config.advisors() {
1446            Ok(seats) if !seats.is_empty() => seats,
1447            Ok(_) => {
1448                self.state.event(
1449                    "advise",
1450                    format!(
1451                        "[graph] advisors is 0; skipping the design-deliberation \
1452                         stage and continuing without a synthesis brief (see {})",
1453                        where_recorded.display()
1454                    ),
1455                );
1456                self.state.advise_attempted = true;
1457                self.state.save()?;
1458                return Ok(());
1459            }
1460            Err(e) => {
1461                self.state.event(
1462                    "advise",
1463                    format!(
1464                        "could not resolve advisor seats ({e:#}); continuing \
1465                         without a design-deliberation brief (see {})",
1466                        where_recorded.display()
1467                    ),
1468                );
1469                self.state.advise_attempted = true;
1470                self.state.save()?;
1471                return Ok(());
1472            }
1473        };
1474
1475        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1476        let artifacts = agent::artifacts_dir(&self.state.dir());
1477        let worktrees: Vec<PathBuf> = (1..=n).map(|k| root.join(format!("advisor-{k}"))).collect();
1478
1479        let mut jobs = Vec::new();
1480        for (i, spec) in seats.iter().cloned().enumerate() {
1481            let seat_key = format!("advisor-{}", i + 1);
1482            let seat = self.seat(&seat_key, &spec.id);
1483            jobs.push(SeatJob {
1484                prompt: prompt::advisor(&instruction, i + 1, seats.len(), &language),
1485                spec,
1486                seat,
1487                cwd: worktrees[i % worktrees.len()].clone(),
1488                timeout,
1489                allow_write: false,
1490                sessions: false,
1491                artifacts: artifacts.clone(),
1492                stem: seat_key,
1493                handover: None,
1494            });
1495        }
1496
1497        self.state.event(
1498            "advise",
1499            format!(
1500                "{} advisor seat(s) sketching a design in parallel",
1501                jobs.len()
1502            ),
1503        );
1504        let mut quota_losses = Vec::new();
1505        let cache = self.state.config.cache_dir();
1506        let ctx = WaveCtx {
1507            carry_seats: false,
1508            run: &run_id,
1509            node: "advise",
1510            prompts: &prompts,
1511            cache: cache.as_deref(),
1512            round: None,
1513        };
1514        let advisor_roster = self.state.config.advisor_roster().unwrap_or_default();
1515        let results = ask_json_wave::<Proposal>(
1516            jobs,
1517            Arc::clone(&self.sem),
1518            self.state.config.graph.retries,
1519            &advisor_roster,
1520            &ctx,
1521            &mut quota_losses,
1522            &mut self.state,
1523            &|p: &Proposal| p.validate(),
1524        )
1525        .await;
1526        self.state.quota.extend(quota_losses);
1527
1528        let mut records = Vec::with_capacity(results.len());
1529        for (i, (seat, res, _attempts)) in results.into_iter().enumerate() {
1530            let agent_id = seat.agent.clone();
1531            self.state.seats.insert(seat.key.clone(), seat);
1532            match res {
1533                Ok((proposal, out)) => {
1534                    self.state
1535                        .event("advise", format!("advisor-{} proposed a design", i + 1));
1536                    records.push(advise::AdvisorRecord::proposed(
1537                        i + 1,
1538                        agent_id,
1539                        proposal,
1540                        out.duration_ms,
1541                    ));
1542                }
1543                Err(e) => {
1544                    self.state.event(
1545                        "advise",
1546                        format!("advisor-{} produced no usable proposal: {e:#}", i + 1),
1547                    );
1548                    records.push(advise::AdvisorRecord::failed(
1549                        i + 1,
1550                        agent_id,
1551                        e.to_string(),
1552                    ));
1553                }
1554            }
1555        }
1556
1557        let mut advice = advise::Advice {
1558            records,
1559            synthesis: None,
1560        };
1561        if advice.proposals().is_empty() {
1562            self.state.event(
1563                "advise",
1564                "no advisor produced a usable proposal; continuing without a \
1565                 synthesis brief"
1566                    .to_owned(),
1567            );
1568        } else {
1569            match self
1570                .synthesize_brief(
1571                    &advice,
1572                    &instruction,
1573                    &language,
1574                    &worktrees[0],
1575                    &artifacts,
1576                    &run_id,
1577                    &prompts,
1578                    cache.as_deref(),
1579                )
1580                .await
1581            {
1582                Ok(Some(text)) => {
1583                    self.state.event(
1584                        "advise",
1585                        "synthesized a design brief for the implementer".to_owned(),
1586                    );
1587                    advice.synthesis = Some(text);
1588                }
1589                Ok(None) => {
1590                    self.state.event(
1591                        "advise",
1592                        "the synthesis seat produced nothing usable; continuing \
1593                         without a design brief"
1594                            .to_owned(),
1595                    );
1596                }
1597                Err(e) => {
1598                    self.state.event(
1599                        "advise",
1600                        format!("could not synthesize a design brief: {e:#}"),
1601                    );
1602                }
1603            }
1604        }
1605        advise::apply_reflection(&mut advice);
1606
1607        self.state.advice = Some(advice);
1608        self.state.advise_attempted = true;
1609        self.state.save()?;
1610        Ok(())
1611    }
1612
1613    /// The synthesis seat: reads every advisor's proposal and blends them
1614    /// into the design brief `advise` stores on [`RunState::advice`]. Split
1615    /// out of [`Runner::advise`] only for readability — it is not called
1616    /// anywhere else.
1617    ///
1618    /// Picked the same way [`crate::talk`]'s standing conversation and
1619    /// [`crate::bump`]'s release-bump decision are: [`agent::pick`], with
1620    /// `[roles] synthesizer` checked first and [`agent::pick`]'s own default
1621    /// order (a claude seat, else the first runnable agent in roster order)
1622    /// used when that field is unset — see `[roles] synthesizer`'s own doc
1623    /// in [`crate::config`] for why a dedicated field exists here at all.
1624    #[allow(clippy::too_many_arguments)]
1625    async fn synthesize_brief(
1626        &mut self,
1627        advice: &advise::Advice,
1628        instruction: &str,
1629        language: &str,
1630        cwd: &Path,
1631        artifacts: &Path,
1632        run_id: &str,
1633        prompts: &Prompts,
1634        cache: Option<&Path>,
1635    ) -> Result<Option<String>> {
1636        let chain = agent::pick_chain(
1637            &self.state.config.agents,
1638            self.state.config.roles.synthesizer.as_ref(),
1639            &agent::installed,
1640            "synthesizer",
1641        )?;
1642        let proposals = advice.proposals();
1643        let mut prompt = prompt::with_overlay(
1644            prompt::synthesize_brief(instruction, &proposals, language),
1645            prompts.overlay("advise"),
1646        );
1647        if cache.is_some() {
1648            // This seat never writes, so it is never handed `CARGO_TARGET_DIR`
1649            // below — see `prompt::build_cache_note`'s doc for why telling a
1650            // read-only seat to build through the shared cache is exactly how
1651            // a sandbox's write refusal gets misread as a defect.
1652            prompt.push('\n');
1653            prompt.push_str(&prompt::build_cache_note("advise", false));
1654        }
1655        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge.max(1));
1656        // Each id is tried once, in order; a quota hit, error or unusable
1657        // answer moves to the next. The seat is single-turn (`sessions:
1658        // false`) and the prompt is the whole context, so a fallback agent
1659        // needs nothing carried over.
1660        let mut last = None;
1661        for (n, spec) in chain.iter().enumerate() {
1662            if n > 0 {
1663                self.state
1664                    .event("advise", format!("synthesis falling back to {}", spec.id));
1665            }
1666            let mut seat = self.seat("advise-synthesis", &spec.id);
1667            let outcome = agent::invoke(
1668                spec,
1669                &mut seat,
1670                &Invocation {
1671                    cwd,
1672                    prompt: &prompt,
1673                    timeout,
1674                    allow_write: false,
1675                    unsandboxed: false,
1676                    sessions: false,
1677                    artifacts,
1678                    stem: &if n == 0 {
1679                        "advise-synthesis".to_owned()
1680                    } else {
1681                        format!("advise-synthesis-{}", spec.id)
1682                    },
1683                    run: run_id,
1684                    node: "advise",
1685                    cache_dir: None,
1686                    attachments: &[],
1687                    writable: &[],
1688                },
1689            )
1690            .await;
1691            if outcome.is_ok() {
1692                self.state.seats.insert(seat.key.clone(), seat);
1693            }
1694            let advance = agent::chain_advances(&outcome);
1695            last = Some(outcome);
1696            if !advance {
1697                break;
1698            }
1699        }
1700        // Exhausted: the last attempt's result is what a single failed seat
1701        // would have produced.
1702        let out = last.expect("a chain holds at least one agent")?;
1703        if !out.usable() {
1704            return Ok(None);
1705        }
1706        let text =
1707            verdict::section(&out.text, "synthesis").unwrap_or_else(|| out.text.trim().to_owned());
1708        Ok((!text.trim().is_empty()).then_some(text))
1709    }
1710
1711    // ----------------------------------------------------------- implement
1712
1713    async fn implement(&mut self) -> Result<()> {
1714        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
1715        // agent files with `magi task add` name the run that paid for it. The
1716        // prompt overlay is cloned alongside it because the waves borrow it
1717        // while `self` is mutably borrowed by the node's own bookkeeping.
1718        let run_id = self.state.id.clone();
1719        let prompts = self.state.config.prompts.clone();
1720        let todo: Vec<usize> = self
1721            .state
1722            .candidates
1723            .iter()
1724            .enumerate()
1725            .filter(|(_, c)| c.commits == 0 && c.failed.is_none() && !c.empty)
1726            .map(|(i, _)| i)
1727            .collect();
1728        if todo.is_empty() {
1729            return self.after_implement();
1730        }
1731        self.state.status = RunStatus::Implementing;
1732
1733        let language = self.state.config.graph.language.clone();
1734        let timeout = Duration::from_secs(self.state.config.graph.timeout_implement);
1735        let sessions = self.state.config.graph.sessions;
1736        let artifacts = agent::artifacts_dir(&self.state.dir());
1737        // The design-deliberation stage's blended brief, when `advise` found
1738        // one — carried into every implementer's prompt the same way
1739        // regardless of which candidate it is.
1740        let brief = self
1741            .state
1742            .advice
1743            .as_ref()
1744            .and_then(|a| a.synthesis.as_deref())
1745            .map(str::to_owned);
1746        let attachments = self.state.attachments.clone();
1747
1748        let mut jobs = Vec::new();
1749        for &i in &todo {
1750            let (index, label, worktree) = {
1751                let c = &self.state.candidates[i];
1752                (c.index, c.label, c.worktree.clone())
1753            };
1754            let spec = self.roles.implementers[index].clone();
1755            let seat_key = format!("impl-{label}");
1756            let seat = self.seat(&seat_key, &spec.id);
1757            let instruction = seeded_instruction(&self.state);
1758            jobs.push(SeatJob {
1759                spec,
1760                seat,
1761                prompt: prompt::implement(
1762                    &instruction,
1763                    &worktree.to_string_lossy(),
1764                    &language,
1765                    brief.as_deref(),
1766                    &attachments,
1767                ),
1768                cwd: worktree,
1769                timeout,
1770                allow_write: true,
1771                sessions,
1772                artifacts: artifacts.clone(),
1773                stem: format!("impl-{label}"),
1774                handover: None,
1775            });
1776        }
1777
1778        self.state.event(
1779            "implement",
1780            format!("{} candidates in parallel", jobs.len()),
1781        );
1782        // Kept so a seat whose CLI hung up can be asked again from the same
1783        // job: `wave` consumes what it is given. Mutable so `resume_seat_handovers`
1784        // can update a seat's own entry once a fallback agent takes it over —
1785        // `resume_unconfirmed_commands`, which reads `sent` afterward, must see
1786        // whichever agent actually answered, not the one that quota'd out.
1787        let mut sent = jobs.clone();
1788        let cache = self.state.config.cache_dir();
1789        let ctx = WaveCtx {
1790            carry_seats: false,
1791            run: &run_id,
1792            node: "implement",
1793            prompts: &prompts,
1794            cache: cache.as_deref(),
1795            round: None,
1796        };
1797        let mut results = wave(jobs, Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
1798        self.resume_undelivered(&mut results, &sent, &prompts, &run_id)
1799            .await;
1800        self.resume_seat_handovers(&mut results, &mut sent, &prompts, &run_id)
1801            .await;
1802        self.resume_unconfirmed_commands(&mut results, &sent, &prompts, &run_id)
1803            .await;
1804
1805        for (&i, (_wi, seat, out)) in todo.iter().zip(results) {
1806            let seat_key = seat.key.clone();
1807            // A quota fallback (`resume_seat_handovers`) may have handed this
1808            // seat to a different agent than the one `prep` recorded on the
1809            // candidate; the stats tables and any later fixer-defaults-to-
1810            // winner's-author lookup must credit whoever actually answered —
1811            // unless every fallback also quota'd out, in which case nobody
1812            // actually answered and crediting the last agent tried would
1813            // erase every earlier agent's own quota loss from the stats
1814            // tables instead of just this one seat's.
1815            let agent = seat.agent.clone();
1816            let exhausted_the_fallback_chain = FailClass::of(&out).is_some();
1817            self.state.seats.insert(seat.key.clone(), seat);
1818            let label = self.state.candidates[i].label;
1819            let worktree = self.state.candidates[i].worktree.clone();
1820            let base = self.state.base_commit.clone();
1821
1822            let (summary, duration, failed, verified_claim) = match out {
1823                AgentOutcome::Ok(o) => {
1824                    let text = verdict::section(&o.text, "summary").unwrap_or(o.text.clone());
1825                    let failed = (!o.usable()).then(|| {
1826                        if o.timed_out {
1827                            "agent timed out".to_owned()
1828                        } else {
1829                            format!("agent exited with {:?}", o.exit_code)
1830                        }
1831                    });
1832                    let verified_claim = verified_noop_claim(failed.is_none(), &o.commands, &text);
1833                    (text, o.duration_ms, failed, verified_claim)
1834                }
1835                // Left un-resumed by `resume_undelivered` (a dirty tree
1836                // already rescues the work, or there was no session left to
1837                // resume into) — reported like the ordinary failure it is,
1838                // never as if `o.text` (the CLI's raw error JSON) were an
1839                // answer.
1840                AgentOutcome::Dropped(o) => {
1841                    let why = o
1842                        .dropped
1843                        .as_ref()
1844                        .map(|d| d.why.as_str())
1845                        .unwrap_or("the CLI ended the stream without delivering its answer");
1846                    (
1847                        String::new(),
1848                        o.duration_ms,
1849                        Some(format!("the CLI dropped the stream ({why})")),
1850                        None,
1851                    )
1852                }
1853                AgentOutcome::Quota(o) => {
1854                    self.state.quota.push(QuotaLoss {
1855                        seat: seat_key,
1856                        node: "implement".to_owned(),
1857                        at: Timestamp::now(),
1858                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
1859                    });
1860                    (
1861                        String::new(),
1862                        o.duration_ms,
1863                        Some("rate limited (quota); produced no change".to_owned()),
1864                        None,
1865                    )
1866                }
1867                AgentOutcome::Failed(e) => (String::new(), 0, Some(e), None),
1868            };
1869
1870            // Rescue anything the agent edited but never committed: an
1871            // uncommitted candidate would silently be an empty one.
1872            let rescued = match git::rescue_commit(
1873                &worktree,
1874                &format!("magi: candidate {label} (uncommitted work)"),
1875            )
1876            .await
1877            {
1878                Ok(r) => {
1879                    self.state.note_withheld("implement", &r.withheld);
1880                    r.committed
1881                }
1882                Err(_) => false,
1883            };
1884            let commits = git::commits_ahead(&worktree, &base, "HEAD")
1885                .await
1886                .unwrap_or(0);
1887            let patch = git::diff(&worktree, &base, "HEAD")
1888                .await
1889                .unwrap_or_default();
1890            let stat = git::diff_stat(&worktree, &base, "HEAD")
1891                .await
1892                .unwrap_or_default();
1893            let files = git::changed_files(&worktree, &base, "HEAD")
1894                .await
1895                .map(|f| f.len())
1896                .unwrap_or(0);
1897            write_artifact(&self.state, &format!("cand-{label}.patch"), &patch)?;
1898
1899            let c = &mut self.state.candidates[i];
1900            if !exhausted_the_fallback_chain {
1901                c.agent = agent;
1902            }
1903            c.summary = blind::sanitize_prose(&summary, &self.state.config.blind);
1904            c.stat = stat;
1905            c.files = files;
1906            c.commits = commits;
1907            c.duration_ms = duration;
1908            c.empty = commits == 0 || patch.trim().is_empty();
1909            // An agent that failed but still produced a committed change stays
1910            // in the running: the patch is what gets judged, not the exit code.
1911            c.failed = match failed {
1912                Some(_) if c.empty => failed,
1913                _ => None,
1914            };
1915            // Only an empty candidate can be a verified no-op: a claim next
1916            // to a real patch is not what the marker is for, and `c.failed`
1917            // being `Some` here already implies `verified_claim` was never
1918            // set (see the guard above the match that produced it).
1919            c.verified_noop = if c.empty { verified_claim } else { None };
1920            let note = match (&c.failed, c.empty, &c.verified_noop, rescued) {
1921                (Some(e), _, _, _) => format!("candidate {label}: {e}"),
1922                (None, true, Some(_), _) => {
1923                    format!("candidate {label}: no change produced (agent-verified no-op)")
1924                }
1925                (None, true, None, _) => format!("candidate {label}: no change produced"),
1926                (None, false, _, true) => {
1927                    format!(
1928                        "candidate {label}: {files} files, {commits} commits (rescued an uncommitted tree)"
1929                    )
1930                }
1931                (None, false, _, false) => {
1932                    format!("candidate {label}: {files} files, {commits} commits")
1933                }
1934            };
1935            self.state.event("implement", note);
1936            self.state.save()?;
1937        }
1938
1939        self.after_implement()
1940    }
1941
1942    /// Ask again, once, for work a CLI did and then failed to hand over.
1943    ///
1944    /// [`agent::dropped_stream`] recognises the one shape observed: an error
1945    /// status with an empty response and a usage report showing output tokens,
1946    /// i.e. **billed work with nothing delivered**. Run 26c7's candidate B was
1947    /// seven minutes and 14,267 output tokens that arrived as an empty
1948    /// candidate, because `agy`'s own subscriber fell behind and hung up.
1949    ///
1950    /// Two conditions, and both matter:
1951    ///
1952    /// - **Only when the tree is untouched.** Often the agent has already
1953    ///   written its files and only the closing message was lost; the rescue
1954    ///   commit below picks that up and there is nothing to ask for. Re-asking
1955    ///   then would pay for a second implementation of work already on disk.
1956    /// - **Once.** A CLI that drops one stream can drop the next, and this
1957    ///   node is the most expensive in the graph.
1958    ///
1959    /// The re-ask is a resume, not a re-run: `has_context` is true because the
1960    /// dropped reply still carried its `conversation_id`, so the seat is asked
1961    /// to finish what it was doing rather than sent the whole task again. It
1962    /// therefore gets a nudge's budget ([`retry_budget`]) - a quarter of the
1963    /// node's - for the same reason a re-ranked judge does: restating finished
1964    /// work is not the work.
1965    ///
1966    /// Unlike a quota this is worth retrying at all: a rate limit fails the
1967    /// same way until it resets, while an abandoned conversation is still
1968    /// there to be picked up.
1969    async fn resume_undelivered(
1970        &mut self,
1971        results: &mut [(usize, SeatState, AgentOutcome)],
1972        sent: &[SeatJob],
1973        prompts: &Prompts,
1974        run_id: &str,
1975    ) {
1976        for (wi, seat, out) in results.iter_mut() {
1977            let Some(dropped) = (match &*out {
1978                AgentOutcome::Dropped(o) => o.dropped.clone(),
1979                _ => None,
1980            }) else {
1981                continue;
1982            };
1983            let Some(job) = sent.get(*wi) else { continue };
1984            // Already on disk? Then only the closing message was lost.
1985            if !git::is_clean(&job.cwd).await.unwrap_or(true) {
1986                self.state.event(
1987                    "implement",
1988                    format!(
1989                        "{}: the CLI dropped the stream after {} output tokens ({}), but the \
1990                         work is in the tree",
1991                        seat.key, dropped.output_tokens, dropped.why
1992                    ),
1993                );
1994                continue;
1995            }
1996            // The re-ask only makes sense as a resume: `resume_after_drop`
1997            // says nothing about the task, trusting the seat to still hold it.
1998            // Without a session to resume — sessions disabled, or this CLI's
1999            // drop shape happened not to carry a session id — that prompt
2000            // would open a brand-new conversation with no context at all,
2001            // which is worse than leaving this as the ordinary failure it
2002            // already is.
2003            if !has_context(&job.spec, seat, job.sessions) {
2004                self.state.event(
2005                    "implement",
2006                    format!(
2007                        "{}: the CLI dropped the stream after {} output tokens ({}), but there \
2008                         is no session left to resume",
2009                        seat.key, dropped.output_tokens, dropped.why
2010                    ),
2011                );
2012                continue;
2013            }
2014            self.state.event(
2015                "implement",
2016                format!(
2017                    "{}: the CLI dropped the stream after {} output tokens ({}); resuming the \
2018                     conversation",
2019                    seat.key, dropped.output_tokens, dropped.why
2020                ),
2021            );
2022            let mut retry = job.clone();
2023            retry.seat = seat.clone();
2024            retry.prompt = prompt::resume_after_drop(&dropped.why);
2025            retry.timeout = retry_budget(job.timeout, true);
2026            retry.stem = format!("{}-resume", job.stem);
2027            let cache = self.state.config.cache_dir();
2028            let ctx = WaveCtx {
2029                carry_seats: false,
2030                run: run_id,
2031                node: "implement",
2032                prompts,
2033                cache: cache.as_deref(),
2034                round: None,
2035            };
2036            let (resumed_seat, resumed) =
2037                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2038            *seat = resumed_seat;
2039            *out = resumed;
2040        }
2041    }
2042
2043    /// Fall an implement seat through to the next untried agent in the
2044    /// implementer roster when it lost to quota — or, since the handover was
2045    /// generalised, to a timeout or an ordinary failure (see [`FailClass`] and
2046    /// [`should_hand_over`] for when a non-quota failure stops the chain), the
2047    /// quota path itself being unchanged — instead of leaving the
2048    /// seat's loss final the moment one agent's account runs dry.
2049    ///
2050    /// Solo runs (`graph.implementers = 1`, `daemon::apply_solo`'s forced shape)
2051    /// are the motivating case: `Config::resolve_roles`'s `implementers`
2052    /// truncates to the single slot rotation picked, so a solo task whose one
2053    /// implementer hits quota mid-run used to have nothing else to try. This
2054    /// walks [`ResolvedRoles::implementer_roster`] instead — the untruncated,
2055    /// unrotated roster — which is the only place the *other* candidates in
2056    /// the machine's roster still exist once `implementers` has been cut down
2057    /// to size.
2058    ///
2059    /// Walks forward from just past the seat's own original position in the
2060    /// roster, never wrapping back to the front: a later candidate slot (say
2061    /// `beta`, the roster's second entry) must fall through to the *next*
2062    /// entry (`gamma`) on its own quota loss, not back to `alpha`, which is
2063    /// almost certainly a different candidate's own agent already — and once
2064    /// the roster's tail is exhausted there is nothing left to fall through
2065    /// to for *this* seat, wrapping or not. Tried by `spec.id`, never the
2066    /// whole [`AgentSpec`]: a roster with the same id named twice must not
2067    /// let this retry that id forever. The loop keeps falling through until
2068    /// an attempt lands something other than `Quota` or the roster's tail
2069    /// runs out of untried ids, at which point the seat is left exactly as
2070    /// `implement`'s own `AgentOutcome::Quota` arm already handles it: one
2071    /// `QuotaLoss` recorded, the candidate failed/empty.
2072    ///
2073    /// `sent` is taken mutably and updated with the fallback agent's spec:
2074    /// `resume_unconfirmed_commands`, which runs after this and also reads
2075    /// `sent`, must see whichever agent actually ended up answering the seat
2076    /// — reading the stale, original spec there would check session
2077    /// eligibility against the wrong CLI and could hand a fallback agent's
2078    /// session id to the agent that just lost the seat to quota.
2079    ///
2080    /// Every fallback gets a fresh [`SeatState`], never the quota'd seat's own
2081    /// — `self.seat` only reuses state when the agent id is unchanged, so
2082    /// handing it a different id already gets this for free. Reusing the old
2083    /// seat would resume a different CLI's session as if it were a
2084    /// continuation of this one.
2085    ///
2086    /// Unlike [`Runner::resume_undelivered`], not gated on a clean worktree:
2087    /// a quota loss cuts an agent off mid-turn, so anything already in the
2088    /// tree is unfinished work, not a completed candidate a re-ask would pay
2089    /// for twice. A dirty tree is rescued into a commit first (the same
2090    /// neutral-identity rescue `implement`'s own outcome loop gives every
2091    /// candidate) so the next agent starts clean.
2092    ///
2093    /// The new agent gets the implementer's full prompt and full
2094    /// `timeout_implement` budget, not `resume_after_drop`'s nudge-sized one:
2095    /// it has no session and no context, and is implementing the task from
2096    /// nothing, unlike a resumed drop which is only restating work already
2097    /// done.
2098    ///
2099    /// Every intermediate `Quota` this loop absorbs is folded into a plain
2100    /// `implement` event, never into `self.state.quota` — that is what
2101    /// `daemon.rs`'s own backoff reads to decide a run's task attempt should
2102    /// go unspent, and a seat that ultimately recovered on its second or
2103    /// third agent is not the stalled panel that check exists to catch. Only
2104    /// the final, unrecovered `Quota` (once the roster runs out) ever reaches
2105    /// `self.state.quota`, via the ordinary `AgentOutcome::Quota` arm the
2106    /// outcome loop already has — this helper never pushes to it itself.
2107    async fn resume_seat_handovers(
2108        &mut self,
2109        results: &mut [(usize, SeatState, AgentOutcome)],
2110        sent: &mut [SeatJob],
2111        prompts: &Prompts,
2112        run_id: &str,
2113    ) {
2114        let instruction = seeded_instruction(&self.state);
2115        let language = self.state.config.graph.language.clone();
2116        let brief = self
2117            .state
2118            .advice
2119            .as_ref()
2120            .and_then(|a| a.synthesis.as_deref())
2121            .map(str::to_owned);
2122        let attachments = self.state.attachments.clone();
2123        for (wi, seat, out) in results.iter_mut() {
2124            // Who holds the other candidate seats of this wave right now
2125            // (earlier handovers already written back to `sent`).
2126            let others: BTreeSet<String> = sent
2127                .iter()
2128                .enumerate()
2129                .filter(|(j, _)| j != wi)
2130                .map(|(_, j)| j.spec.id.clone())
2131                .collect();
2132            let Some(job) = sent.get_mut(*wi) else {
2133                continue;
2134            };
2135            // Where the seat's own original agent sits in the roster — the
2136            // fallback walk starts just past here, never at the front, so a
2137            // later candidate slot's quota loss does not fall back onto an
2138            // earlier slot's own agent.
2139            let start = self
2140                .roles
2141                .implementer_roster
2142                .iter()
2143                .position(|s| s.id == job.spec.id)
2144                .unwrap_or(0);
2145            let mut tried: BTreeSet<String> = BTreeSet::from([job.spec.id.clone()]);
2146            let mut fallback_attempt = 0usize;
2147            let mut prev: Option<FailClass> = None;
2148            while let Some(cur) = FailClass::of(&*out) {
2149                if !should_hand_over(prev.as_ref(), &cur) {
2150                    break;
2151                }
2152                let Some(next) =
2153                    pick_successor(&self.roles.implementer_roster, start, &tried, None, &others)
2154                        .cloned()
2155                else {
2156                    break;
2157                };
2158                tried.insert(next.id.clone());
2159                fallback_attempt += 1;
2160
2161                if let Ok(r) = git::rescue_commit(
2162                    &job.cwd,
2163                    &format!(
2164                        "magi: candidate {} (uncommitted work before {} fallback)",
2165                        seat.key,
2166                        if cur == FailClass::Quota {
2167                            "quota"
2168                        } else {
2169                            "handover"
2170                        }
2171                    ),
2172                )
2173                .await
2174                {
2175                    self.state.note_withheld("implement", &r.withheld);
2176                }
2177
2178                record_handover(
2179                    &mut self.state,
2180                    "implement",
2181                    &seat.key,
2182                    &seat.agent,
2183                    &next.id,
2184                    &cur,
2185                    &fail_reason(&*out),
2186                );
2187                prev = Some(cur.clone());
2188
2189                let new_seat = handover_seat(&seat.key, &next.id, self.state.next_seat_seed());
2190                self.state.seats.insert(seat.key.clone(), new_seat.clone());
2191                // Kept in sync on `sent` itself, not just the local retry: a
2192                // later helper (`resume_unconfirmed_commands`) reads `sent`
2193                // after this one returns and must see whichever agent is now
2194                // occupying the seat, not the one that just quota'd out —
2195                // otherwise it would judge session/continuation eligibility
2196                // by the wrong CLI and could resend a fallback's session id
2197                // to the agent that lost it the seat in the first place.
2198                job.spec = next.clone();
2199                let mut retry = job.clone();
2200                retry.seat = new_seat;
2201                retry.prompt = prompt::implement(
2202                    &instruction,
2203                    &job.cwd.to_string_lossy(),
2204                    &language,
2205                    brief.as_deref(),
2206                    &attachments,
2207                );
2208                retry.stem = format!("{}-{}-{}", job.stem, cur.stem_word(), next.id);
2209                let cache = self.state.config.cache_dir();
2210                let ctx = WaveCtx {
2211                    carry_seats: false,
2212                    run: run_id,
2213                    node: "implement",
2214                    prompts,
2215                    cache: cache.as_deref(),
2216                    round: None,
2217                };
2218                let (fallback_seat, fallback_out) = run_one(
2219                    retry,
2220                    Arc::clone(&self.sem),
2221                    &ctx,
2222                    &mut self.state,
2223                    fallback_attempt,
2224                )
2225                .await;
2226                *seat = fallback_seat;
2227                *out = fallback_out;
2228            }
2229        }
2230    }
2231
2232    /// Ask an implement seat's own CLI to confirm what it started, once, when
2233    /// its reply reported a command whose completion status it never
2234    /// confirmed — see [`has_unconfirmed_command`]'s own doc for exactly what
2235    /// that does and does not mean.
2236    ///
2237    /// The completion contract this task asks for, extended to `implement`
2238    /// with the same signal `continue_fix_report` reads for the fixer,
2239    /// rather than a keyword search over the reply or a hard requirement on
2240    /// `## SUMMARY`'s presence — the shape behind fb35, 9566 and e185, where
2241    /// a candidate's CLI turn ended cleanly while a test run it had started
2242    /// had not. A short, ordinary reply with no `## SUMMARY` and no commands
2243    /// named in it at all is untouched by this: `commands` is empty, so
2244    /// there is nothing to be unconfirmed.
2245    ///
2246    /// Unlike `resume_undelivered`, not gated on the tree being untouched:
2247    /// this is not about recovering edits that might already be on disk, it
2248    /// is about a result the seat itself never vouched for, which resuming
2249    /// asks for regardless of what the tree already holds. Bounded to one
2250    /// attempt for the same reason `resume_undelivered` is — this is the
2251    /// most expensive node in the graph — and a seat that still cannot
2252    /// confirm on that attempt is left as whatever its (possibly still
2253    /// unconfirmed) reply says; this does not invent a new "failed" reason
2254    /// for a candidate that otherwise produced a real, committed change.
2255    async fn resume_unconfirmed_commands(
2256        &mut self,
2257        results: &mut [(usize, SeatState, AgentOutcome)],
2258        sent: &[SeatJob],
2259        prompts: &Prompts,
2260        run_id: &str,
2261    ) {
2262        for (wi, seat, out) in results.iter_mut() {
2263            let AgentOutcome::Ok(o) = &*out else {
2264                continue;
2265            };
2266            if !has_unconfirmed_command(&o.commands) {
2267                continue;
2268            }
2269            let Some(job) = sent.get(*wi) else { continue };
2270            if !has_context(&job.spec, seat, job.sessions) {
2271                self.state.event(
2272                    "implement",
2273                    format!(
2274                        "{}: the reply named a command whose own CLI never confirmed the exit \
2275                         status of, but there is no session left to resume",
2276                        seat.key
2277                    ),
2278                );
2279                continue;
2280            }
2281            self.state.event(
2282                "implement",
2283                format!(
2284                    "{}: the reply named a command whose own CLI never confirmed the exit \
2285                     status of; resuming the conversation",
2286                    seat.key
2287                ),
2288            );
2289            let mut retry = job.clone();
2290            retry.seat = seat.clone();
2291            retry.prompt = prompt::resume_incomplete(
2292                "a command in your last reply had no confirmed exit status",
2293            );
2294            retry.timeout = retry_budget(job.timeout, true);
2295            retry.stem = format!("{}-confirm", job.stem);
2296            let cache = self.state.config.cache_dir();
2297            let ctx = WaveCtx {
2298                carry_seats: false,
2299                run: run_id,
2300                node: "implement",
2301                prompts,
2302                cache: cache.as_deref(),
2303                round: None,
2304            };
2305            let (resumed_seat, resumed) =
2306                run_one(retry, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
2307            *seat = resumed_seat;
2308            *out = resumed;
2309        }
2310    }
2311
2312    /// Ask the fixer's own seat again, up to [`MAX_FIX_CONTINUATIONS`] times,
2313    /// when its CLI turn ended cleanly (`AgentOutcome::Ok`) but the reply held
2314    /// no [`FixReport`] — see [`MAX_FIX_CONTINUATIONS`]'s own doc for the run
2315    /// that motivated this.
2316    ///
2317    /// Not the same gap as an unparsable *shape*, which [`ask_json_wave`]'s
2318    /// own nudge loop already covers for judge/review/vote seats, and not a
2319    /// dropped stream, which [`Runner::resume_undelivered`] covers for
2320    /// implement seats: here the CLI turn genuinely finished while the node's
2321    /// own work — the fixer's account of what it did — had not. Gated purely
2322    /// on `extract_json::<FixReport>` having failed on an otherwise-usable
2323    /// reply, never on any wording in it, so a fixer whose valid, first-try
2324    /// `FixReport` happens to mention having waited on a background test is
2325    /// never resumed — the `Ok(report)` branch at the call site returns
2326    /// before this is ever invoked.
2327    ///
2328    /// Same discipline as `resume_undelivered`: a nudge-sized timeout per
2329    /// attempt ([`retry_budget`]), nothing attempted once the session is
2330    /// gone, and a quota hit ends the loop immediately rather than retrying a
2331    /// rate limit that fails the same way again.
2332    async fn continue_fix_report(
2333        &mut self,
2334        mut seat: SeatState,
2335        parse_err: String,
2336        job: &SeatJob,
2337        prompts: &Prompts,
2338        run_id: &str,
2339        round: usize,
2340    ) -> (
2341        SeatState,
2342        Option<FixReport>,
2343        Option<String>,
2344        ContinuationRecord,
2345    ) {
2346        let mut last_err = parse_err;
2347        let mut cumulative_wait_ms = 0u64;
2348        let mut attempts = 0usize;
2349        loop {
2350            if !has_context(&job.spec, &seat, job.sessions) {
2351                self.state.event(
2352                    "fix",
2353                    format!(
2354                        "round {round}: fixer's reply had no adoption report ({last_err}); no \
2355                         session left to resume into"
2356                    ),
2357                );
2358                let outcome = if attempts == 0 {
2359                    ContinuationOutcome::NoSession
2360                } else {
2361                    ContinuationOutcome::Exhausted
2362                };
2363                return (
2364                    seat,
2365                    None,
2366                    Some(format!("unparsable fix report: {last_err}")),
2367                    ContinuationRecord {
2368                        attempts,
2369                        cumulative_wait_ms,
2370                        outcome,
2371                    },
2372                );
2373            }
2374            if attempts >= MAX_FIX_CONTINUATIONS {
2375                self.state.event(
2376                    "fix",
2377                    format!(
2378                        "round {round}: fixer's reply still had no adoption report after \
2379                         {attempts} continuation(s) ({last_err}); giving up"
2380                    ),
2381                );
2382                return (
2383                    seat,
2384                    None,
2385                    Some(format!(
2386                        "unparsable fix report after {attempts} continuation(s): {last_err}"
2387                    )),
2388                    ContinuationRecord {
2389                        attempts,
2390                        cumulative_wait_ms,
2391                        outcome: ContinuationOutcome::Exhausted,
2392                    },
2393                );
2394            }
2395            attempts += 1;
2396            self.state.event(
2397                "fix",
2398                format!(
2399                    "round {round}: fixer's reply had no adoption report ({last_err}); resuming \
2400                     the conversation (attempt {attempts}/{MAX_FIX_CONTINUATIONS})"
2401                ),
2402            );
2403            let mut retry = job.clone();
2404            retry.seat = seat.clone();
2405            retry.prompt = prompt::resume_incomplete(&last_err);
2406            retry.timeout = retry_budget(job.timeout, true);
2407            retry.stem = format!("{}-continue{attempts}", job.stem);
2408            let cache = self.state.config.cache_dir();
2409            let ctx = WaveCtx {
2410                carry_seats: false,
2411                run: run_id,
2412                node: "fix",
2413                prompts,
2414                cache: cache.as_deref(),
2415                round: Some(round),
2416            };
2417            let (resumed_seat, resumed_out) = run_one(
2418                retry,
2419                Arc::clone(&self.sem),
2420                &ctx,
2421                &mut self.state,
2422                attempts,
2423            )
2424            .await;
2425            seat = resumed_seat;
2426            match resumed_out {
2427                AgentOutcome::Ok(o) => {
2428                    cumulative_wait_ms += o.duration_ms;
2429                    match verdict::extract_json::<FixReport>(&o.text) {
2430                        Ok(report) if !has_unconfirmed_command(&o.commands) => {
2431                            self.state.event(
2432                                "fix",
2433                                format!(
2434                                    "round {round}: fixer's adoption report recovered after \
2435                                     {attempts} continuation(s)"
2436                                ),
2437                            );
2438                            return (
2439                                seat,
2440                                Some(report),
2441                                None,
2442                                ContinuationRecord {
2443                                    attempts,
2444                                    cumulative_wait_ms,
2445                                    outcome: ContinuationOutcome::Resumed,
2446                                },
2447                            );
2448                        }
2449                        // The report parsed, but this same reply's own
2450                        // CommandEvidence — the identical record `state.jobs`
2451                        // renders — names a command whose CLI never
2452                        // confirmed an exit status. Read together, that is
2453                        // not a resolved answer: keep nudging rather than
2454                        // accept a report standing next to a command the
2455                        // seat's own CLI cannot vouch for.
2456                        Ok(_) => {
2457                            last_err = "the reply parsed, but it reported a command whose own CLI \
2458                                 never confirmed an exit status"
2459                                .to_owned();
2460                        }
2461                        Err(e) => last_err = e.to_string(),
2462                    }
2463                }
2464                AgentOutcome::Quota(o) => {
2465                    cumulative_wait_ms += o.duration_ms;
2466                    self.state.quota.push(QuotaLoss {
2467                        seat: seat.key.clone(),
2468                        node: "fix".to_owned(),
2469                        at: Timestamp::now(),
2470                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
2471                    });
2472                    self.state.event(
2473                        "fix",
2474                        format!(
2475                            "round {round}: continuation rate limited (quota); not retrying now"
2476                        ),
2477                    );
2478                    return (
2479                        seat,
2480                        None,
2481                        Some("rate limited (quota) while recovering the fix report".to_owned()),
2482                        ContinuationRecord {
2483                            attempts,
2484                            cumulative_wait_ms,
2485                            outcome: ContinuationOutcome::QuotaLost,
2486                        },
2487                    );
2488                }
2489                AgentOutcome::Dropped(o) => {
2490                    cumulative_wait_ms += o.duration_ms;
2491                    let why = o
2492                        .dropped
2493                        .as_ref()
2494                        .map(|d| d.why.as_str())
2495                        .unwrap_or("the CLI ended the stream without delivering its answer");
2496                    last_err = format!("the CLI dropped the stream ({why})");
2497                }
2498                AgentOutcome::Failed(e) => last_err = e,
2499            }
2500        }
2501    }
2502
2503    fn after_implement(&mut self) -> Result<()> {
2504        // Scan every candidate patch once the set is complete.
2505        if self.state.leaks.is_empty() {
2506            let cfg = self.state.config.blind.clone();
2507            let mut leaks = Vec::new();
2508            for c in &self.state.candidates {
2509                let Some(patch) =
2510                    crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
2511                else {
2512                    continue;
2513                };
2514                leaks.extend(blind::scan(
2515                    &format!("candidate {} patch", c.label),
2516                    &patch,
2517                    &cfg.vendor_tokens,
2518                ));
2519            }
2520            if !leaks.is_empty() {
2521                let summary = leaks
2522                    .iter()
2523                    .map(|l| format!("{}×{} in {}", l.token, l.count, l.site))
2524                    .collect::<Vec<_>>()
2525                    .join(", ");
2526                match cfg.on_leak {
2527                    LeakPolicy::Fail => {
2528                        self.state.status = RunStatus::Failed;
2529                        self.state
2530                            .event("blind", format!("vendor text in a patch: {summary}"));
2531                        self.state.leaks = leaks;
2532                        self.state.save()?;
2533                        self.settle_questions();
2534                        bail!(
2535                            "blind.on_leak = \"fail\" and vendor text reached a \
2536                             judged patch: {summary}"
2537                        );
2538                    }
2539                    LeakPolicy::Redact => self.state.event(
2540                        "blind",
2541                        format!("redacting vendor text for judging: {summary}"),
2542                    ),
2543                    LeakPolicy::Warn => self.state.event(
2544                        "blind",
2545                        format!("vendor text present in a judged patch (shown as-is): {summary}"),
2546                    ),
2547                }
2548                self.state.leaks = leaks;
2549            }
2550        }
2551
2552        if self.state.viable().is_empty() {
2553            if self.state.all_candidates_verified_noop() {
2554                // Every candidate agreed, with evidence the adoption guard
2555                // accepted, that nothing belongs in this worktree. That is
2556                // not the same fact as a candidate that simply failed to
2557                // write anything, and settling it as an ordinary `Failed`
2558                // (see `SCHEMA`'s doc for schema 10) is what let two of
2559                // task 391f's attempts burn a retry each re-discovering the
2560                // same already-landed fix. Terminal either way, so `judge`
2561                // must never run over an empty candidate set — unlike the
2562                // `Failed` branch below this returns `Ok`, not an error:
2563                // nothing here failed.
2564                self.state.status = RunStatus::VerifiedNoop;
2565                self.state.save()?;
2566                self.settle_questions();
2567                return Ok(());
2568            }
2569            self.state.status = RunStatus::Failed;
2570            self.state.save()?;
2571            self.settle_questions();
2572            bail!("no candidate produced a change; nothing to judge");
2573        }
2574        self.state.status = RunStatus::Judging;
2575        self.state.save()?;
2576        Ok(())
2577    }
2578
2579    // --------------------------------------------------------------- judge
2580
2581    async fn judge(&mut self) -> Result<()> {
2582        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2583        // agent files with `magi task add` name the run that paid for it. The
2584        // prompt overlay is cloned alongside it because the waves borrow it
2585        // while `self` is mutably borrowed by the node's own bookkeeping.
2586        let run_id = self.state.id.clone();
2587        let prompts = self.state.config.prompts.clone();
2588        if !self.state.judgements.is_empty() || self.state.judge_skipped {
2589            return Ok(());
2590        }
2591        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2592        if viable.len() == 1 {
2593            // Recorded so this is a one-time event: `judgements` stays empty
2594            // either way, which without this flag is indistinguishable from
2595            // "not yet judged" on the next reentry — and status is left
2596            // untouched, so a later node's conclusion (e.g. `Blocked` after
2597            // the review budget ran out) survives a resume instead of being
2598            // clobbered back to `Judging` by this node running again.
2599            self.state.judge_skipped = true;
2600            self.state.event(
2601                "judge",
2602                format!(
2603                    "only candidate {} produced a change; judging skipped",
2604                    viable[0].label
2605                ),
2606            );
2607            self.state.save()?;
2608            return Ok(());
2609        }
2610        self.state.status = RunStatus::Judging;
2611
2612        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
2613        let language = self.state.config.graph.language.clone();
2614        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2615        let sessions = self.state.config.graph.sessions;
2616        let artifacts = agent::artifacts_dir(&self.state.dir());
2617        let root = self.state.worktree_root();
2618        let base_short = short(&self.state.base_commit);
2619
2620        let mut jobs = Vec::new();
2621        let mut orders = Vec::new();
2622        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2623            let order = blind::presentation_order(viable.len(), j, self.state.seed);
2624            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
2625            orders.push(order.iter().map(|&k| viable[k].index).collect::<Vec<_>>());
2626            let seat_key = format!("judge-{}", j + 1);
2627            let seat = self.seat(&seat_key, &spec.id);
2628            jobs.push(SeatJob {
2629                prompt: prompt::judge(
2630                    &self.state.instruction,
2631                    &views,
2632                    self.roles.judges.len(),
2633                    &base_short,
2634                    &language,
2635                ),
2636                spec,
2637                seat,
2638                cwd: root.join(format!("judge-{}", j + 1)),
2639                timeout,
2640                allow_write: false,
2641                sessions,
2642                artifacts: artifacts.clone(),
2643                stem: format!("judge-{}", j + 1),
2644                handover: None,
2645            });
2646        }
2647
2648        self.state.event(
2649            "judge",
2650            format!(
2651                "{} judges ranking {} candidates blind",
2652                jobs.len(),
2653                viable.len()
2654            ),
2655        );
2656        let labels_for_check = labels.clone();
2657        let mut quota_losses = Vec::new();
2658        let cache = self.state.config.cache_dir();
2659        let ctx = WaveCtx {
2660            carry_seats: false,
2661            run: &run_id,
2662            node: "judge",
2663            prompts: &prompts,
2664            cache: cache.as_deref(),
2665            round: None,
2666        };
2667        let results = ask_json_wave::<Ranking>(
2668            jobs,
2669            Arc::clone(&self.sem),
2670            self.state.config.graph.retries,
2671            &self.roles.judge_roster,
2672            &ctx,
2673            &mut quota_losses,
2674            &mut self.state,
2675            &move |r: &Ranking| r.validate(&labels_for_check),
2676        )
2677        .await;
2678        self.state.quota.extend(quota_losses);
2679
2680        for (j, (seat, res, _attempts)) in results.into_iter().enumerate() {
2681            let agent_id = seat.agent.clone();
2682            self.state.seats.insert(seat.key.clone(), seat);
2683            let mut record = Judgement {
2684                judge: j + 1,
2685                seat: format!("judge-{}", j + 1),
2686                agent: agent_id,
2687                ranking: Vec::new(),
2688                reasons: BTreeMap::new(),
2689                confidence: None,
2690                order: orders[j].clone(),
2691                failed: None,
2692                duration_ms: 0,
2693            };
2694            match res {
2695                Ok((ranking, out)) => {
2696                    record.ranking = ranking.normalized();
2697                    record.reasons = ranking.reasons;
2698                    record.confidence = ranking.confidence;
2699                    record.duration_ms = out.duration_ms;
2700                    self.state.event(
2701                        "judge",
2702                        format!(
2703                            "judge {} ranked {}",
2704                            j + 1,
2705                            record.ranking.iter().collect::<String>()
2706                        ),
2707                    );
2708                }
2709                Err(e) => {
2710                    record.failed = Some(e.to_string());
2711                    self.state
2712                        .event("judge", format!("judge {} produced no ranking: {e}", j + 1));
2713                }
2714            }
2715            self.state.judgements.push(record);
2716            self.state.save()?;
2717        }
2718        Ok(())
2719    }
2720
2721    // ---------------------------------------------------------- deliberate
2722
2723    async fn deliberate(&mut self) -> Result<()> {
2724        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2725        // agent files with `magi task add` name the run that paid for it. The
2726        // prompt overlay is cloned alongside it because the waves borrow it
2727        // while `self` is mutably borrowed by the node's own bookkeeping.
2728        let run_id = self.state.id.clone();
2729        let prompts = self.state.config.prompts.clone();
2730        if !self.state.deliberation.is_empty() {
2731            return Ok(());
2732        }
2733        let tops: Vec<char> = self
2734            .state
2735            .judgements
2736            .iter()
2737            .filter_map(|j| j.ranking.first().copied())
2738            .collect();
2739        let rounds = self.state.config.graph.deliberate_rounds;
2740        if tops.len() < 2 || tops.iter().all(|t| *t == tops[0]) || rounds == 0 {
2741            if tops.len() >= 2 && tops.iter().all(|t| *t == tops[0]) {
2742                self.state.event(
2743                    "deliberate",
2744                    format!("judges agreed on {} outright; no deliberation", tops[0]),
2745                );
2746            }
2747            self.state.status = RunStatus::Voting;
2748            self.state.save()?;
2749            return Ok(());
2750        }
2751
2752        self.state.status = RunStatus::Deliberating;
2753        self.state.event(
2754            "deliberate",
2755            format!(
2756                "split: first choices were {} — opening {rounds} round(s)",
2757                tops.iter().collect::<String>()
2758            ),
2759        );
2760
2761        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2762        let language = self.state.config.graph.language.clone();
2763        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2764        let sessions = self.state.config.graph.sessions;
2765        let artifacts = agent::artifacts_dir(&self.state.dir());
2766        let root = self.state.worktree_root();
2767        let base_short = short(&self.state.base_commit);
2768
2769        // Judges argue in sequence so that a turn can answer the one before it;
2770        // that is the difference between deliberation and three parallel
2771        // monologues.
2772        for round in 1..=rounds {
2773            let mut turns: Vec<DeliberationTurn> = Vec::new();
2774            for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2775                if self.state.judgements[j].failed.is_some() {
2776                    continue;
2777                }
2778                let seat_key = format!("judge-{}", j + 1);
2779                let spec = self.occupant(&seat_key, spec);
2780                let mut seat = self.seat(&seat_key, &spec.id);
2781                let transcript = self.transcript(&turns, j);
2782                let build = |context: Option<&str>| {
2783                    prompt::deliberate(
2784                        &self.state.instruction,
2785                        context,
2786                        &transcript,
2787                        round,
2788                        rounds,
2789                        &language,
2790                    )
2791                };
2792                let block = self.candidate_block(&viable, &base_short);
2793                let full = build(Some(&block));
2794                let text = if has_context(&spec, &seat, sessions) {
2795                    build(None)
2796                } else {
2797                    full.clone()
2798                };
2799                let job = SeatJob {
2800                    spec,
2801                    seat: seat.clone(),
2802                    prompt: text,
2803                    cwd: root.join(format!("judge-{}", j + 1)),
2804                    timeout,
2805                    allow_write: false,
2806                    sessions,
2807                    artifacts: artifacts.clone(),
2808                    stem: format!("delib-{round}-judge-{}", j + 1),
2809                    handover: Some(full),
2810                };
2811                let cache = self.state.config.cache_dir();
2812                let ctx = WaveCtx {
2813                    carry_seats: false,
2814                    run: &run_id,
2815                    node: "deliberate",
2816                    prompts: &prompts,
2817                    cache: cache.as_deref(),
2818                    round: None,
2819                };
2820                // A turn is never nudged (`retries` 0): a failed seat is
2821                // handed to the next roster agent, which gets the full
2822                // context. An empty answer is a turn, not a failure.
2823                let mut losses = Vec::new();
2824                let mut results = ask_wave_with::<String>(
2825                    vec![job],
2826                    Arc::clone(&self.sem),
2827                    0,
2828                    &self.roles.judge_roster,
2829                    &ctx,
2830                    &mut losses,
2831                    &mut self.state,
2832                    &|text: &str| {
2833                        Ok(verdict::section(text, "position").unwrap_or_else(|| text.to_owned()))
2834                    },
2835                )
2836                .await;
2837                self.state.quota.extend(losses);
2838                let (updated, res, _) = results.pop().expect("one job in, one result out");
2839                seat = updated;
2840                let agent_id = seat.agent.clone();
2841                self.state.seats.insert(seat.key.clone(), seat);
2842                let body = match res {
2843                    Ok((body, _)) => body,
2844                    // Skip the seat; a CLI's raw error JSON is never read as
2845                    // this judge's position.
2846                    Err(e) => {
2847                        self.state
2848                            .event("deliberate", format!("judge {} skipped: {e}", j + 1));
2849                        continue;
2850                    }
2851                };
2852                let tentative = verdict::extract_json::<Position>(&body)
2853                    .ok()
2854                    .and_then(|p| p.tentative)
2855                    .and_then(|s| s.trim().chars().next())
2856                    .map(|c| c.to_ascii_uppercase());
2857                self.state.event(
2858                    "deliberate",
2859                    format!(
2860                        "round {round}: judge {} now favours {}",
2861                        j + 1,
2862                        tentative.map_or("—".to_owned(), |c| c.to_string())
2863                    ),
2864                );
2865                turns.push(DeliberationTurn {
2866                    judge: j + 1,
2867                    agent: agent_id,
2868                    body: blind::sanitize_prose(&body, &self.state.config.blind),
2869                    tentative,
2870                });
2871            }
2872            self.state
2873                .deliberation
2874                .push(DeliberationRound { round, turns });
2875            self.state.save()?;
2876        }
2877
2878        self.state.status = RunStatus::Voting;
2879        self.state.save()?;
2880        Ok(())
2881    }
2882
2883    // ---------------------------------------------------------------- vote
2884
2885    async fn vote(&mut self) -> Result<()> {
2886        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
2887        // agent files with `magi task add` name the run that paid for it. The
2888        // prompt overlay is cloned alongside it because the waves borrow it
2889        // while `self` is mutably borrowed by the node's own bookkeeping.
2890        let run_id = self.state.id.clone();
2891        let prompts = self.state.config.prompts.clone();
2892        if !self.state.votes.is_empty() {
2893            return Ok(());
2894        }
2895        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
2896        if viable.len() == 1 {
2897            return Ok(());
2898        }
2899        self.state.status = RunStatus::Voting;
2900
2901        let language = self.state.config.graph.language.clone();
2902        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
2903        let sessions = self.state.config.graph.sessions;
2904        let artifacts = agent::artifacts_dir(&self.state.dir());
2905        let root = self.state.worktree_root();
2906        let base_short = short(&self.state.base_commit);
2907        let candidates: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
2908
2909        let mut jobs = Vec::new();
2910        let mut seats_at = Vec::new();
2911        for (j, spec) in self.roles.judges.clone().into_iter().enumerate() {
2912            if self
2913                .state
2914                .judgements
2915                .get(j)
2916                .is_some_and(|r| r.failed.is_some())
2917            {
2918                continue;
2919            }
2920            let seat_key = format!("judge-{}", j + 1);
2921            let spec = self.occupant(&seat_key, spec);
2922            let seat = self.seat(&seat_key, &spec.id);
2923            let full = self.vote_prompt_full(j, &viable, &language, &candidates, &base_short);
2924            let text = if has_context(&spec, &seat, sessions) {
2925                prompt::final_vote(&viable, &language)
2926            } else {
2927                full.clone()
2928            };
2929            jobs.push(SeatJob {
2930                spec,
2931                seat,
2932                prompt: text,
2933                cwd: root.join(format!("judge-{}", j + 1)),
2934                timeout,
2935                allow_write: false,
2936                sessions,
2937                artifacts: artifacts.clone(),
2938                stem: format!("vote-judge-{}", j + 1),
2939                handover: Some(full),
2940            });
2941            seats_at.push(j);
2942        }
2943
2944        self.state.event(
2945            "vote",
2946            format!(
2947                "collecting {} final votes one by one, privately",
2948                jobs.len()
2949            ),
2950        );
2951        let allowed = viable.clone();
2952        let mut quota_losses = Vec::new();
2953        let cache = self.state.config.cache_dir();
2954        let ctx = WaveCtx {
2955            carry_seats: false,
2956            run: &run_id,
2957            node: "vote",
2958            prompts: &prompts,
2959            cache: cache.as_deref(),
2960            round: None,
2961        };
2962        let results = ask_json_wave::<FinalVote>(
2963            jobs,
2964            Arc::clone(&self.sem),
2965            self.state.config.graph.retries,
2966            &self.roles.judge_roster,
2967            &ctx,
2968            &mut quota_losses,
2969            &mut self.state,
2970            &move |v: &FinalVote| match v.label() {
2971                Some(c) if allowed.contains(&c) => Ok(()),
2972                other => bail!("vote {other:?} is not one of {allowed:?}"),
2973            },
2974        )
2975        .await;
2976        self.state.quota.extend(quota_losses);
2977
2978        for (&j, (seat, res, _attempts)) in seats_at.iter().zip(results) {
2979            let agent_id = seat.agent.clone();
2980            self.state.seats.insert(seat.key.clone(), seat);
2981            let initial = self
2982                .state
2983                .judgements
2984                .get(j)
2985                .and_then(|r| r.ranking.first().copied());
2986            let mut record = VoteRecord {
2987                judge: j + 1,
2988                agent: agent_id,
2989                vote: None,
2990                reason: String::new(),
2991                changed: false,
2992            };
2993            match res {
2994                Ok((v, _)) => {
2995                    record.vote = v.label();
2996                    record.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
2997                    record.changed = matches!((record.vote, initial), (Some(a), Some(b)) if a != b);
2998                    self.state.event(
2999                        "vote",
3000                        format!(
3001                            "judge {} voted {}{}",
3002                            j + 1,
3003                            record.vote.unwrap_or('?'),
3004                            if record.changed { " (changed)" } else { "" }
3005                        ),
3006                    );
3007                }
3008                Err(e) => {
3009                    self.state
3010                        .event("vote", format!("judge {} cast no vote: {e}", j + 1));
3011                }
3012            }
3013            self.state.votes.push(record);
3014            self.state.save()?;
3015        }
3016        Ok(())
3017    }
3018
3019    // --------------------------------------------------------------- tally
3020
3021    fn tally(&mut self) -> Result<()> {
3022        if self.state.tally.is_some() {
3023            return Ok(());
3024        }
3025        let viable: Vec<char> = self.state.viable().into_iter().map(|c| c.label).collect();
3026        let tops: Vec<char> = self
3027            .state
3028            .judgements
3029            .iter()
3030            .filter_map(|j| j.ranking.first().copied())
3031            .collect();
3032        let unanimous_initial = tops.len() > 1 && tops.iter().all(|t| *t == tops[0]);
3033
3034        // A judge whose private vote failed still counted once, in the initial
3035        // ranking; using it beats discarding a whole seat.
3036        let mut first_choice: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3037        let mut cast: Vec<char> = Vec::new();
3038        for (i, j) in self.state.judgements.iter().enumerate() {
3039            let vote = self
3040                .state
3041                .votes
3042                .iter()
3043                .find(|v| v.judge == i + 1)
3044                .and_then(|v| v.vote)
3045                .or_else(|| j.ranking.first().copied());
3046            if let Some(v) = vote {
3047                *first_choice.entry(v).or_insert(0) += 1;
3048                cast.push(v);
3049            }
3050        }
3051
3052        let mut borda: BTreeMap<char, usize> = viable.iter().map(|l| (*l, 0)).collect();
3053        for j in &self.state.judgements {
3054            let n = j.ranking.len();
3055            for (pos, label) in j.ranking.iter().enumerate() {
3056                *borda.entry(*label).or_insert(0) += n.saturating_sub(pos + 1);
3057            }
3058        }
3059
3060        let best = first_choice.values().copied().max().unwrap_or(0);
3061        let mut leaders: Vec<char> = first_choice
3062            .iter()
3063            .filter(|(_, v)| **v == best)
3064            .map(|(k, _)| *k)
3065            .collect();
3066        let mut tie_break = None;
3067        if leaders.len() > 1 {
3068            let top_borda = leaders.iter().map(|l| borda[l]).max().unwrap_or(0);
3069            let borda_leaders: Vec<char> = leaders
3070                .iter()
3071                .copied()
3072                .filter(|l| borda[l] == top_borda)
3073                .collect();
3074            tie_break = Some(if borda_leaders.len() == 1 {
3075                format!(
3076                    "{} way tie on first-choice votes, broken by Borda points from the initial rankings",
3077                    leaders.len()
3078                )
3079            } else {
3080                format!(
3081                    "{} way tie on both first-choice votes and Borda points, broken by label order",
3082                    leaders.len()
3083                )
3084            });
3085            leaders = borda_leaders;
3086            leaders.sort_unstable();
3087        }
3088        let winner = *leaders
3089            .first()
3090            .or(viable.first())
3091            .context("no candidate to declare a winner from")?;
3092
3093        let changed_votes = self.state.votes.iter().filter(|v| v.changed).count();
3094        let unanimous_final = !cast.is_empty() && cast.iter().all(|c| *c == cast[0]);
3095        let deliberated = !self.state.deliberation.is_empty();
3096
3097        // Whose verdict is this? A rate-limited seat is absent even if it
3098        // ranked before the limit hit, so presence is measured against the
3099        // recorded losses, not just "did a ranking ever appear".
3100        let quota_seats: std::collections::BTreeSet<&str> =
3101            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3102        let mut present = 0usize;
3103        for (i, j) in self.state.judgements.iter().enumerate() {
3104            if quota_seats.contains(j.seat.as_str()) {
3105                continue;
3106            }
3107            let ranked = !j.ranking.is_empty() && j.failed.is_none();
3108            let voted = self
3109                .state
3110                .votes
3111                .iter()
3112                .any(|v| v.judge == i + 1 && v.vote.is_some());
3113            if ranked || voted {
3114                present += 1;
3115            }
3116        }
3117        // Strict majority of the configured panel. A bare majority is real
3118        // signal we can act on, while a minority verdict must never stand in
3119        // for a healthy one. A one-candidate run needs no panel at all, and
3120        // `judges` stays `0` rather than the roster size a panel that never
3121        // sat would otherwise be credited with.
3122        let needs_quorum = viable.len() > 1;
3123        let judges_total = if needs_quorum {
3124            self.roles.judges.len()
3125        } else {
3126            0
3127        };
3128        let quorum = if needs_quorum {
3129            judges_total / 2 + 1
3130        } else {
3131            0
3132        };
3133        let met_quorum = !needs_quorum || present >= quorum;
3134        let uncontested = (!needs_quorum).then(|| {
3135            format!("only one candidate ({winner}) produced a usable change; no panel was asked")
3136        });
3137
3138        self.state.event(
3139            "tally",
3140            match &uncontested {
3141                Some(reason) => format!("winner {winner} — {reason}"),
3142                None => format!(
3143                    "winner {winner} — votes {} | initial {} | {} changed | \
3144                     {present}/{judges_total} judges{}",
3145                    first_choice
3146                        .iter()
3147                        .map(|(k, v)| format!("{k}:{v}"))
3148                        .collect::<Vec<_>>()
3149                        .join(" "),
3150                    if unanimous_initial {
3151                        "unanimous"
3152                    } else {
3153                        "split"
3154                    },
3155                    changed_votes,
3156                    if met_quorum {
3157                        String::new()
3158                    } else {
3159                        format!(" — below quorum ({quorum} required)")
3160                    },
3161                ),
3162            },
3163        );
3164        if !met_quorum {
3165            self.state.event(
3166                "stall",
3167                format!(
3168                    "verdict rests on {present} of {judges_total} judges (quorum {quorum}); \
3169                     the run stops here, resumable"
3170                ),
3171            );
3172        }
3173        self.state.tally = Some(Tally {
3174            first_choice,
3175            borda,
3176            winner,
3177            rankings: tops.len(),
3178            unanimous_initial,
3179            deliberated,
3180            changed_votes,
3181            unanimous_final,
3182            tie_break,
3183            judges: judges_total,
3184            present,
3185            quorum,
3186            met_quorum,
3187            uncontested,
3188        });
3189        self.state.status = if met_quorum {
3190            RunStatus::Reviewing
3191        } else {
3192            RunStatus::Stalled
3193        };
3194        self.state.save()?;
3195        Ok(())
3196    }
3197
3198    // ------------------------------------------------------------- recover
3199
3200    /// Re-ask the judge seats `tally` counts as absent, so a `Stalled` run can be
3201    /// resumed toward completion once the transient cause clears.
3202    ///
3203    /// A seat is absent — and therefore re-asked — when `tally` refuses to count
3204    /// it toward the quorum, which is exactly the set of seats whose absence
3205    /// collapsed the panel: struck by a rate limit at *any* node (the quorum must
3206    /// not depend on which node happened to hit the limit), or an ordinary
3207    /// failure (`failed = Some`) that never produced a usable ranking. A healthy
3208    /// seat is never disturbed.
3209    ///
3210    /// A seat that now answers with a usable ranking is "recovered": its
3211    /// `Judgement` is refreshed, its `QuotaLoss`/`failed` state cleared (so
3212    /// `tally` counts it present again), and its vote re-collected. A seat that
3213    /// still fails keeps its loss and stays absent.
3214    ///
3215    /// Returns `true` when the re-tally restores the quorum (the run may proceed
3216    /// to review/gate/merge), `false` when it is still below quorum (the run
3217    /// stays `Stalled`, still resumable for a later retry).
3218    #[allow(clippy::too_many_lines)]
3219    async fn recover_stall(&mut self) -> Result<bool> {
3220        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
3221        // agent files with `magi task add` name the run that paid for it. The
3222        // prompt overlay is cloned alongside it because the waves borrow it
3223        // while `self` is mutably borrowed by the node's own bookkeeping.
3224        let run_id = self.state.id.clone();
3225        let prompts = self.state.config.prompts.clone();
3226        // Absent seats = quota-lost at any node, or failed outright. Mirroring
3227        // `tally`'s presence test (rather than the old quota-judge/vote filter)
3228        // is what keeps a non-quota collapse — or a quota loss recorded at the
3229        // deliberate node — from being a permanent dead-end on `--resume`.
3230        let quota_seats: BTreeSet<&str> =
3231            self.state.quota.iter().map(|q| q.seat.as_str()).collect();
3232        let absent: Vec<String> = self
3233            .state
3234            .judgements
3235            .iter()
3236            .filter(|j| quota_seats.contains(j.seat.as_str()) || j.failed.is_some())
3237            .map(|j| j.seat.clone())
3238            .collect();
3239        if absent.is_empty() {
3240            return Ok(false);
3241        }
3242        let viable: Vec<Candidate> = self.state.viable().into_iter().cloned().collect();
3243        if viable.len() <= 1 {
3244            return Ok(false);
3245        }
3246        let labels: Vec<char> = viable.iter().map(|c| c.label).collect();
3247        let language = self.state.config.graph.language.clone();
3248        let timeout = Duration::from_secs(self.state.config.graph.timeout_judge);
3249        let sessions = self.state.config.graph.sessions;
3250        let artifacts = agent::artifacts_dir(&self.state.dir());
3251        let root = self.state.worktree_root();
3252        let base_short = short(&self.state.base_commit);
3253        let candidates: Vec<Candidate> = viable.clone();
3254
3255        // Map each absent seat key to its 0-based position in `roles.judges`.
3256        let mut positions: Vec<usize> = absent
3257            .iter()
3258            .filter_map(|k| self.state.judgements.iter().position(|r| &r.seat == k))
3259            .collect();
3260        if positions.is_empty() {
3261            return Ok(false);
3262        }
3263        positions.sort_unstable();
3264        positions.dedup();
3265
3266        // Re-rank the lost seats, one blind prompt each.
3267        let mut judge_jobs = Vec::new();
3268        for &j in &positions {
3269            let order = blind::presentation_order(viable.len(), j, self.state.seed);
3270            let views: Vec<CandidateView> = order.iter().map(|&k| self.view(&viable[k])).collect();
3271            let seat_key = format!("judge-{}", j + 1);
3272            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3273            let seat = self.seat(&seat_key, &spec.id);
3274            judge_jobs.push(SeatJob {
3275                spec,
3276                seat,
3277                prompt: prompt::judge(
3278                    &self.state.instruction,
3279                    &views,
3280                    self.roles.judges.len(),
3281                    &base_short,
3282                    &language,
3283                ),
3284                cwd: root.join(seat_key),
3285                timeout,
3286                allow_write: false,
3287                sessions,
3288                artifacts: artifacts.clone(),
3289                stem: format!("judge-{}-recover", j + 1),
3290                handover: None,
3291            });
3292        }
3293
3294        let labels_for_check = labels.clone();
3295        let mut judge_losses = Vec::new();
3296        let retries = self.state.config.graph.retries;
3297        let cache = self.state.config.cache_dir();
3298        let ctx = WaveCtx {
3299            carry_seats: false,
3300            run: &run_id,
3301            node: "judge",
3302            prompts: &prompts,
3303            cache: cache.as_deref(),
3304            round: None,
3305        };
3306        let results = ask_json_wave::<Ranking>(
3307            judge_jobs,
3308            Arc::clone(&self.sem),
3309            retries,
3310            &self.roles.judge_roster,
3311            &ctx,
3312            &mut judge_losses,
3313            &mut self.state,
3314            &move |r: &Ranking| r.validate(&labels_for_check),
3315        )
3316        .await;
3317
3318        // Refresh the judgement of every seat that ranked again.
3319        let mut recovered: BTreeSet<usize> = BTreeSet::new();
3320        for (&j, (seat, res, _attempts)) in positions.iter().zip(results) {
3321            let agent_id = seat.agent.clone();
3322            self.state.seats.insert(seat.key.clone(), seat);
3323            let record = &mut self.state.judgements[j];
3324            match res {
3325                Ok((ranking, out)) => {
3326                    record.agent = agent_id;
3327                    record.ranking = ranking.normalized();
3328                    record.reasons = ranking.reasons;
3329                    record.confidence = ranking.confidence;
3330                    record.failed = None;
3331                    record.duration_ms = out.duration_ms;
3332                    recovered.insert(j);
3333                    self.state.event(
3334                        "recover",
3335                        format!("judge {} ranked again after the limit", j + 1),
3336                    );
3337                }
3338                Err(e) => {
3339                    self.state
3340                        .event("recover", format!("judge {} still cannot rank: {e}", j + 1));
3341                }
3342            }
3343        }
3344
3345        // Re-ask the votes of the seats that recovered a ranking.
3346        let mut vote_jobs = Vec::new();
3347        let mut vote_pos: Vec<usize> = Vec::new();
3348        for &j in &recovered {
3349            let seat_key = format!("judge-{}", j + 1);
3350            let spec = self.occupant(&seat_key, self.roles.judges[j].clone());
3351            let seat = self.seat(&seat_key, &spec.id);
3352            let full = self.vote_prompt_full(j, &labels, &language, &candidates, &base_short);
3353            let text = if has_context(&spec, &seat, sessions) {
3354                prompt::final_vote(&labels, &language)
3355            } else {
3356                full.clone()
3357            };
3358            vote_jobs.push(SeatJob {
3359                spec,
3360                seat,
3361                prompt: text,
3362                cwd: root.join(seat_key),
3363                timeout,
3364                allow_write: false,
3365                sessions,
3366                artifacts: artifacts.clone(),
3367                stem: format!("vote-judge-{}-recover", j + 1),
3368                handover: Some(full),
3369            });
3370            vote_pos.push(j);
3371        }
3372        let allowed = labels.clone();
3373        let mut vote_losses = Vec::new();
3374        let vote_retries = self.state.config.graph.retries;
3375        let vote_cache = self.state.config.cache_dir();
3376        let ctx = WaveCtx {
3377            carry_seats: false,
3378            run: &run_id,
3379            node: "vote",
3380            prompts: &prompts,
3381            cache: vote_cache.as_deref(),
3382            round: None,
3383        };
3384        let votes = ask_json_wave::<FinalVote>(
3385            vote_jobs,
3386            Arc::clone(&self.sem),
3387            vote_retries,
3388            &self.roles.judge_roster,
3389            &ctx,
3390            &mut vote_losses,
3391            &mut self.state,
3392            &move |v: &FinalVote| match v.label() {
3393                Some(c) if allowed.contains(&c) => Ok(()),
3394                other => bail!("vote {other:?} is not one of {allowed:?}"),
3395            },
3396        )
3397        .await;
3398        for (&j, (seat, res, _attempts)) in vote_pos.iter().zip(votes) {
3399            let agent_id = seat.agent.clone();
3400            self.state.seats.insert(seat.key.clone(), seat);
3401            match res {
3402                Ok((v, _)) => {
3403                    if let Some(rec) = self.state.votes.iter_mut().find(|r| r.judge == j + 1) {
3404                        rec.vote = v.label();
3405                        rec.reason = blind::sanitize_prose(&v.reason, &self.state.config.blind);
3406                    } else {
3407                        self.state.votes.push(VoteRecord {
3408                            judge: j + 1,
3409                            agent: agent_id,
3410                            vote: v.label(),
3411                            reason: blind::sanitize_prose(&v.reason, &self.state.config.blind),
3412                            changed: false,
3413                        });
3414                    }
3415                    self.state.event(
3416                        "recover",
3417                        format!("judge {} voted again after the limit", j + 1),
3418                    );
3419                }
3420                Err(e) => {
3421                    self.state
3422                        .event("recover", format!("judge {} still cannot vote: {e}", j + 1));
3423                }
3424            }
3425        }
3426
3427        // A seat that ranked again is present even if its re-vote failed —
3428        // `tally` falls back to the initial ranking's first choice — so clear
3429        // its quota loss. Seats that still fail keep theirs and stay absent.
3430        let recovered_keys: BTreeSet<String> = recovered
3431            .iter()
3432            .map(|&j| format!("judge-{}", j + 1))
3433            .collect();
3434        self.state
3435            .quota
3436            .retain(|q| !recovered_keys.contains(&q.seat));
3437        // A seat that hit the limit again is a fresh loss, not the old one:
3438        // replace the stale entry so the history stays one-per-seat and the
3439        // daemon can tell this attempt's loss from a previous session's.
3440        for loss in judge_losses.into_iter().chain(vote_losses) {
3441            if recovered_keys.contains(&loss.seat) {
3442                continue;
3443            }
3444            self.state.quota.retain(|q| q.seat != loss.seat);
3445            self.state.quota.push(loss);
3446        }
3447
3448        // Recompute the verdict from the refreshed panel.
3449        self.state.tally = None;
3450        self.tally()?;
3451        Ok(self
3452            .state
3453            .tally
3454            .as_ref()
3455            .map(|t| t.met_quorum)
3456            .unwrap_or(false))
3457    }
3458
3459    // ----------------------------------------------------------------- fold
3460
3461    async fn fold_losers(&mut self) -> Result<()> {
3462        let Some(winner) = self.state.tally.as_ref().map(|t| t.winner) else {
3463            return Ok(());
3464        };
3465        let repo = self.state.repo.clone();
3466        let mut folded = Vec::new();
3467        for i in 0..self.state.candidates.len() {
3468            let c = &self.state.candidates[i];
3469            if c.label == winner || c.folded {
3470                continue;
3471            }
3472            let (wt, branch, label) = (c.worktree.clone(), c.branch.clone(), c.label);
3473            git::worktree_remove(&repo, &wt).await.ok();
3474            git::branch_delete(&repo, &branch).await.ok();
3475            self.state.candidates[i].folded = true;
3476            folded.push(label.to_string());
3477        }
3478        // The judges are finished; their checkouts are pure cost from here.
3479        let root = self.state.worktree_root();
3480        for j in 1..=self.roles.judges.len() {
3481            let wt = root.join(format!("judge-{j}"));
3482            if wt.exists() {
3483                git::worktree_remove(&repo, &wt).await.ok();
3484            }
3485        }
3486        // The design-deliberation stage is finished by the time a tally
3487        // exists — same reasoning as the judges above.
3488        if self.state.config.graph.advise {
3489            for k in 1..=self.state.config.graph.advisors {
3490                let wt = root.join(format!("advisor-{k}"));
3491                if wt.exists() {
3492                    git::worktree_remove(&repo, &wt).await.ok();
3493                }
3494            }
3495        }
3496        if !folded.is_empty() {
3497            self.state
3498                .event("fold", format!("folded candidates {}", folded.join(", ")));
3499            self.state.save()?;
3500        }
3501        Ok(())
3502    }
3503
3504    // ------------------------------------------------------------ base sync
3505
3506    /// Land the winner's tree on the current tip of `<remote>/<base>` before
3507    /// anything verifies it.
3508    ///
3509    /// `verify.e2e`, `verify.gate` and every reviewer in [`Self::review_loop`]
3510    /// read whatever is checked out in the winner's worktree. Left alone that
3511    /// tree stays rooted at `base_commit` - the base as [`resolve_base`] saw
3512    /// it when the run *branched* - and a run takes long enough that the base
3513    /// has usually moved by the time it gets here. A gate that ran there
3514    /// answers "green on the commit this run started from", not "green on
3515    /// what is about to land", and the difference showed up three times in
3516    /// one day as a green run whose merge would have reverted a file another
3517    /// pull request had already landed.
3518    ///
3519    /// Reuses [`crate::rebase::rebase_with_fixer`], the same routine
3520    /// `land::Step::Rebase` calls, rather than a second implementation of the
3521    /// same idea: a throwaway worktree, nothing runs in the primary tree, and
3522    /// a second rebase path is exactly the kind of drift `resolve_base`'s own
3523    /// doc warns about ("two answers to a question nobody notices until a
3524    /// diff is wrong").
3525    ///
3526    /// A conflict is not the end of the road: the standing rebase is handed
3527    /// to the fixer seat, at most `graph.review_rounds` times, counted in
3528    /// `state.rebase_fixes` (so it survives a park/resume and is shared with
3529    /// land). Once it finishes, review and the gate run as usual on the
3530    /// rebased tree, which is where a breakage the new base caused is caught
3531    /// by the ordinary gate-fix round. magi resolves nothing itself.
3532    ///
3533    /// Two different bounds, easy to confuse: [`BASE_SYNC_ROUNDS`], counted in
3534    /// `state.base_sync.attempts`, is how many times the base is *rebased
3535    /// onto* (a base that keeps moving); `rebase_fixes` is how many times a
3536    /// *conflict* was given to a fixer. When the fixer cannot finish the
3537    /// rebase the branch is restored, `state.base_sync.conflict` is set with
3538    /// what was tried (rounds spent, paths still conflicted) and the branch
3539    /// and worktree stay exactly as they were - untouched, for a person to
3540    /// look at - which is also what makes re-entering this function
3541    /// afterwards a no-op instead of a second attempt at the same wall. A
3542    /// push failure ends the same way.
3543    async fn sync_to_base(&mut self) -> Result<()> {
3544        if self.state.status == RunStatus::AlreadyInBase {
3545            return Ok(());
3546        }
3547        let conflicted = self
3548            .state
3549            .base_sync
3550            .as_ref()
3551            .is_some_and(|s| s.conflict.is_some());
3552        let Some(winner) = self.state.winner().cloned() else {
3553            return Ok(());
3554        };
3555
3556        let repo = self.state.repo.clone();
3557        let remote = self.state.config.merge.remote.clone();
3558        let base_branch = self.state.base_branch.clone();
3559        let tracking = format!("{remote}/{base_branch}");
3560
3561        git::fetch(&repo, &remote, &base_branch).await.ok();
3562        // No network, or the remote never had this branch: the run already
3563        // started from a fetched `<remote>/<base>` (`resolve_base` refuses
3564        // otherwise), and one that got this far is not blocked by a fetch
3565        // that fails now. It just has no newer tip to compare against.
3566        let Ok(tip) = git::rev_parse(&repo, &tracking).await else {
3567            return Ok(());
3568        };
3569
3570        let head = git::rev_parse(&winner.worktree, "HEAD").await?;
3571        let behind = git::commits_ahead(&repo, &head, &tip).await.unwrap_or(0);
3572        let attempts = self.state.base_sync.as_ref().map_or(0, |s| s.attempts);
3573
3574        // Before any rebase, and before a recorded conflict is honoured: a
3575        // branch whose change reached the base under other commit ids has
3576        // nothing to rebase and nothing to conflict with, and a run that
3577        // already stopped on that phantom conflict recovers here on resume.
3578        // `behind == 0` with head == tip is a branch the base has since taken
3579        // in whole, whether or not a conflict was ever recorded: the ancestry
3580        // proof must still run (`classify` ignores a head still on the start
3581        // commit).
3582        if (behind > 0 || conflicted || head == tip)
3583            && self
3584                .settle_already_in(&winner.branch, &tip, &head, attempts, behind)
3585                .await?
3586        {
3587            return Ok(());
3588        }
3589        if conflicted {
3590            return Ok(());
3591        }
3592
3593        if behind == 0 {
3594            // A fixer-finished rebase moves the branch ref before the
3595            // winner's worktree is told (`sync_to_head` below). A run that
3596            // died in between resumes here with `behind == 0` and a tree still
3597            // holding the pre-rebase files, which review and the gate would
3598            // then read. That state is exactly: HEAD moved off the tip the
3599            // rebase started from, yet the tree is still identical to that
3600            // tip. A tree with edits of its own differs from it, so nothing
3601            // is thrown away.
3602            if let Some(from) = self
3603                .state
3604                .rebase_fixes
3605                .iter()
3606                .rev()
3607                .find_map(|r| r.from.clone())
3608                && from != head
3609                && git::git_raw(&winner.worktree, &["diff", "--quiet", &from])
3610                    .await
3611                    .is_ok_and(|o| o.ok())
3612            {
3613                git::sync_to_head(&winner.worktree).await?;
3614            }
3615            // An earlier attempt may have rebased the branch locally and died
3616            // before pushing it (only the fresh-rebase arm below pushes).
3617            // Publish it now, so the plain push at PR time is not refused as
3618            // a non-fast-forward. A run already holding a recorded conflict
3619            // never reaches here; that case is out of scope.
3620            let conflict = self.publish_resumed_rebase(&winner.branch, &head).await;
3621            if let Some(why) = &conflict {
3622                self.state.status = RunStatus::Blocked;
3623                self.state.event("land", why.clone());
3624            }
3625            self.state.base_sync = Some(BaseSync {
3626                tip,
3627                behind: 0,
3628                attempts,
3629                conflict,
3630                already_in: None,
3631            });
3632            self.state.save()?;
3633            return Ok(());
3634        }
3635
3636        if attempts >= BASE_SYNC_ROUNDS {
3637            let why = format!(
3638                "{base_branch} moved {behind} commit(s) ahead of {} after {BASE_SYNC_ROUNDS} \
3639                 rebase(s); rebasing again would only race it",
3640                winner.branch
3641            );
3642            self.state.status = RunStatus::Blocked;
3643            self.state.base_sync = Some(BaseSync {
3644                tip,
3645                behind,
3646                attempts,
3647                conflict: Some(why.clone()),
3648                already_in: None,
3649            });
3650            self.state.event("land", why);
3651            self.state.save()?;
3652            return Ok(());
3653        }
3654
3655        self.state.event(
3656            "land",
3657            format!(
3658                "{base_branch} moved {behind} commit(s) ahead of {}; rebasing before verifying",
3659                winner.branch
3660            ),
3661        );
3662        self.state.save()?;
3663
3664        // The remote's copy of the branch, read now and only if the fetch
3665        // really succeeded (a stale tracking ref must never pin a lease). It is
3666        // pushed over after a rebase only when it is a commit this branch
3667        // already contains, by ancestry or by patch (an earlier rebase of ours
3668        // that never reached the remote): anything else is somebody else's work.
3669        let branch_tracking = format!("{remote}/{}", winner.branch);
3670        let fetched_branch = git::fetch(&repo, &remote, &winner.branch).await;
3671        let remote_tip = if matches!(&fetched_branch, Ok(o) if o.ok()) {
3672            git::rev_parse(&repo, &branch_tracking).await.ok()
3673        } else {
3674            None
3675        };
3676        // A remote tip this branch does not contain is somebody else's work:
3677        // rebasing would leave a local tip that can never be pushed. Stop
3678        // before touching anything and say so.
3679        if let Some(theirs) = &remote_tip
3680            && !git::is_ancestor(&repo, theirs, &head).await
3681            && !crate::reconcile::origin_missing(&repo, &head, theirs)
3682                .await
3683                .is_ok_and(|missing| missing.is_empty())
3684        {
3685            let why = format!(
3686                "{branch_tracking} ({}) has commits {} does not contain; not rebasing over \
3687                 them",
3688                short(theirs),
3689                winner.branch
3690            );
3691            self.state.status = RunStatus::Blocked;
3692            self.state.base_sync = Some(BaseSync {
3693                tip,
3694                behind,
3695                attempts,
3696                conflict: Some(why.clone()),
3697                already_in: None,
3698            });
3699            self.state.event("land", why);
3700            self.state.save()?;
3701            return Ok(());
3702        }
3703
3704        let scratch = self.state.dir().join("base-sync");
3705        let rebased = match crate::rebase::rebase_with_fixer(
3706            &mut self.state,
3707            &scratch,
3708            &winner.branch,
3709            &tracking,
3710        )
3711        .await
3712        {
3713            Ok(crate::rebase::Rebased::Applied) => Ok(None),
3714            Ok(crate::rebase::Rebased::Stopped(why)) => Ok(Some(why)),
3715            Err(e) => Err(e),
3716        };
3717        let attempts = attempts + 1;
3718        match rebased {
3719            Ok(None) => {
3720                // The branch ref moved, but a worktree that already had it
3721                // checked out (the winner's) was not told; sync its index and
3722                // files before anything reads them.
3723                git::sync_to_head(&winner.worktree).await?;
3724                refresh_reviewed_commits(&mut self.state, &winner.branch).await;
3725                let mut conflict = None;
3726                if let Some(pinned) = &remote_tip {
3727                    let pushed = git::push_pinned(&repo, &remote, &winner.branch, pinned).await;
3728                    match pushed {
3729                        Ok(o) if o.ok() => self.state.event(
3730                            "land",
3731                            format!("pushed rebased {} to {remote}", winner.branch),
3732                        ),
3733                        Ok(o) => {
3734                            conflict = Some(format!(
3735                                "rebased {} locally but {remote} refused the push (it moved                                  since {}; someone may have pushed): {}",
3736                                winner.branch,
3737                                short(pinned),
3738                                o.stderr.chars().take(600).collect::<String>()
3739                            ));
3740                        }
3741                        Err(e) => {
3742                            conflict = Some(format!(
3743                                "rebased {} locally but could not push it: {e:#}",
3744                                winner.branch
3745                            ));
3746                        }
3747                    }
3748                }
3749                if let Some(why) = &conflict {
3750                    self.state.status = RunStatus::Blocked;
3751                    self.state.event("land", why.clone());
3752                }
3753                self.state.base_sync = Some(BaseSync {
3754                    tip: tip.clone(),
3755                    behind: 0,
3756                    attempts,
3757                    conflict,
3758                    already_in: None,
3759                });
3760                self.state
3761                    .event("land", format!("rebased {} onto {tracking}", winner.branch));
3762            }
3763            Ok(Some(conflict)) => {
3764                let why = format!(
3765                    "{} conflicts with {tracking} and did not rebase: {}",
3766                    winner.branch,
3767                    conflict.chars().take(600).collect::<String>()
3768                );
3769                self.state.status = RunStatus::Blocked;
3770                self.state.base_sync = Some(BaseSync {
3771                    tip,
3772                    behind,
3773                    attempts,
3774                    conflict: Some(why.clone()),
3775                    already_in: None,
3776                });
3777                self.state.event("land", why);
3778            }
3779            Err(e) => {
3780                let why = format!("could not rebase {} onto {tracking}: {e:#}", winner.branch);
3781                self.state.status = RunStatus::Blocked;
3782                self.state.base_sync = Some(BaseSync {
3783                    tip,
3784                    behind,
3785                    attempts,
3786                    conflict: Some(why.clone()),
3787                    already_in: None,
3788                });
3789                self.state.event("land", why);
3790            }
3791        }
3792        self.state.save()?;
3793        Ok(())
3794    }
3795
3796    /// Push a branch an earlier attempt rebased locally but never published,
3797    /// pinned to the remote tip read right after a successful fetch. Returns
3798    /// the reason when the run must stop; `None` when there was nothing to do
3799    /// (no remote copy, the same tip, or a remote copy this branch already
3800    /// contains, which the PR-time push fast-forwards) or the push succeeded.
3801    async fn publish_resumed_rebase(&mut self, branch: &str, head: &str) -> Option<String> {
3802        let repo = self.state.repo.clone();
3803        let remote = self.state.config.merge.remote.clone();
3804        let fetched = git::fetch(&repo, &remote, branch).await;
3805        if !matches!(&fetched, Ok(o) if o.ok()) {
3806            return None;
3807        }
3808        let branch_tracking = format!("{remote}/{branch}");
3809        let theirs = git::rev_parse(&repo, &branch_tracking).await.ok()?;
3810        if theirs == head || git::is_ancestor(&repo, &theirs, head).await {
3811            return None;
3812        }
3813        if !crate::reconcile::origin_missing(&repo, head, &theirs)
3814            .await
3815            .is_ok_and(|missing| missing.is_empty())
3816        {
3817            return Some(format!(
3818                "{branch_tracking} ({}) has commits {branch} does not contain; not pushing over \
3819                 them",
3820                short(&theirs)
3821            ));
3822        }
3823        match git::push_pinned(&repo, &remote, branch, &theirs).await {
3824            Ok(o) if o.ok() => {
3825                self.state
3826                    .event("land", format!("pushed rebased {branch} to {remote}"));
3827                None
3828            }
3829            Ok(o) => Some(format!(
3830                "{branch} is rebased locally but {remote} refused the push (it moved since {}; \
3831                 someone may have pushed): {}",
3832                short(&theirs),
3833                o.stderr.chars().take(600).collect::<String>()
3834            )),
3835            Err(e) => Some(format!(
3836                "{branch} is rebased locally but could not be pushed: {e:#}"
3837            )),
3838        }
3839    }
3840
3841    /// End the run as [`RunStatus::AlreadyInBase`] when `head`'s whole change
3842    /// is already on `tip` under other commit ids ([`crate::already`]); returns
3843    /// whether it did.
3844    ///
3845    /// Checked only when the base is ahead of the branch. A failing check is
3846    /// "not proven" - the ordinary rebase path then decides - never a reason to
3847    /// stop the run.
3848    ///
3849    /// The remote copy of the branch is held to the same standard as the local
3850    /// one: if it carries a tip this worktree does not, that tip must itself be
3851    /// proven in the base, or nothing is settled (a pull request would
3852    /// otherwise be closed over commits nobody checked). The pull request is
3853    /// closed *before* the terminal status is saved; if that fails for a
3854    /// reason other than a refusal (no network, a `gh` error) the run is left
3855    /// `Blocked` with the reason as its conflict, which a resume retries -
3856    /// the same recovery a phantom conflict gets.
3857    async fn settle_already_in(
3858        &mut self,
3859        branch: &str,
3860        tip: &str,
3861        head: &str,
3862        attempts: usize,
3863        behind: usize,
3864    ) -> Result<bool> {
3865        let repo = self.state.repo.clone();
3866        let remote = self.state.config.merge.remote.clone();
3867        let start = self.state.base_commit.clone();
3868        let evidence = match crate::already::classify(&repo, tip, head, Some(&start)).await {
3869            Ok(Some(e)) => e,
3870            Ok(None) => return Ok(false),
3871            Err(e) => {
3872                tracing::warn!("already-in-base check for {branch}: {e:#}");
3873                return Ok(false);
3874            }
3875        };
3876        let mut verified = vec![head.to_owned()];
3877        let fetched = git::fetch(&repo, &remote, branch).await;
3878        if matches!(&fetched, Ok(o) if o.ok())
3879            && let Ok(theirs) = git::rev_parse(&repo, &format!("{remote}/{branch}")).await
3880            && theirs != head
3881        {
3882            match crate::already::classify(&repo, tip, &theirs, Some(&start)).await {
3883                Ok(Some(_)) => verified.push(theirs),
3884                _ => return Ok(false),
3885            }
3886        }
3887        let base_branch = self.state.base_branch.clone();
3888        let message = format!(
3889            "{branch} is already in {remote}/{base_branch} as {} ({} match); nothing left to \
3890             land",
3891            evidence.names(),
3892            evidence.proof.as_str()
3893        );
3894        let closed =
3895            crate::land::close_superseded_pr(&mut self.state, branch, &evidence, &verified).await;
3896        match closed {
3897            Ok(Ok(url)) => self
3898                .state
3899                .event("land", format!("closed {url}: superseded on {base_branch}")),
3900            Ok(Err(why)) => self
3901                .state
3902                .event("land", format!("did not close a pull request: {why}")),
3903            Err(e) => {
3904                let why = format!(
3905                    "{branch} is already in {remote}/{base_branch}, but its pull request could \
3906                     not be closed ({e:#}); resume to retry"
3907                );
3908                self.state.status = RunStatus::Blocked;
3909                self.state.base_sync = Some(BaseSync {
3910                    tip: tip.to_owned(),
3911                    behind,
3912                    attempts,
3913                    conflict: Some(why.clone()),
3914                    already_in: None,
3915                });
3916                self.state.event("land", why);
3917                self.state.save()?;
3918                return Ok(true);
3919            }
3920        }
3921        self.state.status = RunStatus::AlreadyInBase;
3922        self.state.base_sync = Some(BaseSync {
3923            tip: tip.to_owned(),
3924            behind,
3925            attempts,
3926            conflict: None,
3927            already_in: Some(evidence),
3928        });
3929        self.state.event("land", message);
3930        self.state.save()?;
3931        self.settle_questions();
3932        Ok(true)
3933    }
3934
3935    /// The commit review and gate diff against: the tip [`Self::sync_to_base`]
3936    /// last landed the winner on, once it has run, else the commit the run
3937    /// branched from.
3938    ///
3939    /// Only [`Self::review_loop`] reads this. `prep`, `judge`, `deliberate`
3940    /// and `vote` all happen before there is a winner to rebase, so they
3941    /// compare every candidate against the branch point on purpose, and a
3942    /// base that moves after they are already done cannot change an answer
3943    /// they already gave.
3944    fn landing_base(&self) -> String {
3945        self.state
3946            .base_sync
3947            .as_ref()
3948            .map_or_else(|| self.state.base_commit.clone(), |s| s.tip.clone())
3949    }
3950
3951    // ------------------------------------------------------- operator fix
3952
3953    /// Route specific, already-recorded review findings to a fixer for a
3954    /// targeted, out-of-band fix on the winning branch — `magi fix`'s own
3955    /// entry point.
3956    ///
3957    /// Distinct from `review_loop`'s own fix step in three ways: it never
3958    /// runs a reviewer wave, it never spends review-round budget, and what
3959    /// happened is recorded as an [`OperatorFixRequest`] appended to
3960    /// [`RunState::operator_fixes`], never folded into a [`ReviewRound`] —
3961    /// see `run::SCHEMA`'s doc for schema 9 on why a reviewer's own severity
3962    /// and vote must never be rewritten to look like a manufactured blocking
3963    /// verdict.
3964    ///
3965    /// Only meaningful once review has actually concluded: `Ready` (handed
3966    /// off with findings still open, or simply concluded clean while minor
3967    /// findings sat unaddressed) or `Blocked` (round budget spent, or the
3968    /// gate failed). Everything else is refused: a run still in progress
3969    /// should simply be resumed, and a `Merged` run's branch has already
3970    /// landed — reopening *this* run's own record cannot change that, so the
3971    /// answer there is a fresh `magi review <branch>`.
3972    ///
3973    /// A real commit here re-verifies through a fresh, ordinary review-only
3974    /// run on the same branch ([`Self::review`]) rather than reopening this
3975    /// run's own `review_loop`: once any round in this run's history went
3976    /// clean, `review_conclusion` treats that as permanent by design (the
3977    /// same purity `gate`/`merge` rely on for safe reentry), so there is no
3978    /// way to force one more genuine reviewer wave out of *this* run without
3979    /// either rewriting history or weakening that guarantee for every other
3980    /// caller. A review-only run costs nothing extra — no implementation, no
3981    /// judging, no vote — and exercises the exact same review → verify →
3982    /// gate → (human) merge path, unmodified.
3983    pub async fn fix_selected(
3984        &mut self,
3985        ids: &[String],
3986        reason: &str,
3987        allow_stale: bool,
3988    ) -> Result<()> {
3989        let reason = reason.trim();
3990        if reason.is_empty() {
3991            bail!("a fix request needs a reason — that is the operator's own record of why");
3992        }
3993        if ids.is_empty() {
3994            bail!("no finding id given");
3995        }
3996        if !matches!(self.state.status, RunStatus::Ready | RunStatus::Blocked) {
3997            bail!(
3998                "run {} is `{}`; only a `ready` or `blocked` run — one whose review \
3999                 has already concluded — can be given a targeted fix. A run still \
4000                 in progress should simply be resumed; a `merged` run's branch has \
4001                 already landed, so its answer is a fresh `magi review <branch>`, \
4002                 not reopening this run's own record",
4003                self.state.id,
4004                self.state.status.as_str()
4005            );
4006        }
4007        let Some(winner) = self.state.winner().cloned() else {
4008            bail!("run {} has no winning candidate to fix", self.state.id);
4009        };
4010        if !git::branch_exists(&self.state.repo, &winner.branch).await? {
4011            bail!(
4012                "branch `{}` no longer exists; this run cannot be extended",
4013                winner.branch
4014            );
4015        }
4016        let home = crate::run::home();
4017        if crate::daemon::is_working_on(&home, &self.state.id, Timestamp::now()) {
4018            bail!(
4019                "run {} is currently being worked on by another magi process",
4020                self.state.id
4021            );
4022        }
4023        // Held for the rest of this call, including the follow-up review
4024        // below: two `magi fix` invocations against the same run must not
4025        // both reach the worktree manipulation further down, which would
4026        // otherwise race to remove and recreate the same directory — see
4027        // [`FixClaim`]'s own doc.
4028        let _claim = FixClaim::acquire(&self.state.dir())?;
4029
4030        // Resolve every id before spending anything — an unknown id refuses
4031        // the whole request rather than silently dropping it — and dedup
4032        // while keeping the operator's own order.
4033        let mut seen = BTreeSet::new();
4034        let mut findings = Vec::new();
4035        let mut missing = Vec::new();
4036        for id in ids {
4037            if !seen.insert(id.clone()) {
4038                continue;
4039            }
4040            match self.state.finding(id) {
4041                Some((round, rec, f)) => findings.push(OperatorFixFinding {
4042                    id: f.id.clone(),
4043                    severity: f.severity,
4044                    reviewer_vote: rec.vote,
4045                    round: round.round,
4046                    round_head: round.head.clone(),
4047                    reviewer: rec.reviewer,
4048                    agent: rec.agent.clone(),
4049                    file: f.file.clone(),
4050                    line: f.line,
4051                    title: f.title.clone(),
4052                    detail: f.detail.clone(),
4053                    outcome: OperatorFixOutcome::Pending,
4054                }),
4055                None => missing.push(id.clone()),
4056            }
4057        }
4058        if !missing.is_empty() {
4059            bail!(
4060                "unknown finding id(s): {}; nothing was changed",
4061                missing.join(", ")
4062            );
4063        }
4064
4065        let head_at_request = git::rev_parse(&self.state.repo, &winner.branch).await?;
4066        let stale_details: Vec<(String, String)> = findings
4067            .iter()
4068            .filter(|f| f.round_head != head_at_request)
4069            .map(|f| (f.id.clone(), f.round_head.clone()))
4070            .collect();
4071        let stale = !stale_details.is_empty();
4072        if stale && !allow_stale {
4073            bail!(
4074                "the branch has moved since some finding(s) were raised — {} — now \
4075                 at {}; pass --allow-stale to fix anyway, or re-run review first",
4076                stale_details
4077                    .iter()
4078                    .map(|(id, head)| format!("{id} (raised against {})", short(head)))
4079                    .collect::<Vec<_>>()
4080                    .join(", "),
4081                short(&head_at_request)
4082            );
4083        }
4084
4085        let request = OperatorFixRequest {
4086            requested_at: Timestamp::now(),
4087            reason: reason.to_owned(),
4088            findings,
4089            head_at_request: head_at_request.clone(),
4090            allow_stale,
4091            stale,
4092            fix: None,
4093            result_head: None,
4094            follow_up_review_run: None,
4095        };
4096        self.state.event(
4097            "fix",
4098            format!(
4099                "operator requested a targeted fix on {} finding(s) ({}): {reason}",
4100                request.findings.len(),
4101                request
4102                    .findings
4103                    .iter()
4104                    .map(|f| f.id.as_str())
4105                    .collect::<Vec<_>>()
4106                    .join(", "),
4107            ),
4108        );
4109        // Recorded now, before any worktree work or the fixer call itself —
4110        // and re-saved at each checkpoint below: a crash at any point after
4111        // this (mid fixer call, mid follow-up review) must not lose the fact
4112        // that this was requested, for which findings, and why. Everything
4113        // past this point reads and writes through `request_index` rather
4114        // than a local variable, since `request` itself is moved here.
4115        self.state.operator_fixes.push(request);
4116        self.state.save()?;
4117        let request_index = self.state.operator_fixes.len() - 1;
4118
4119        // A fresh, dedicated worktree for this one call, never the winner's
4120        // own worktree in place: that one may already be gone (folded away),
4121        // and reusing it in place would leave the branch checked out there
4122        // when the follow-up review below tries to check it out again. Freed
4123        // immediately after, either way — but only once confirmed clean:
4124        // `worktree_remove` is a `git worktree remove --force`, which would
4125        // otherwise discard uncommitted work left there by the operator or
4126        // another process before this had a chance to even look at it.
4127        if winner.worktree.exists() {
4128            // Lockfiles a rescue commit withheld stay untracked on purpose and
4129            // are already recorded; they are not the operator's work to protect.
4130            let dirty = git::git(
4131                &winner.worktree,
4132                &["status", "--porcelain", "--untracked-files=all"],
4133            )
4134            .await?;
4135            let only_withheld = dirty.lines().all(|l| {
4136                l.strip_prefix("?? ")
4137                    .is_some_and(|p| self.state.withheld.iter().any(|w| w.path == p))
4138            });
4139            if !only_withheld {
4140                bail!(
4141                    "`{}` has uncommitted changes; refusing to touch it — commit or \
4142                     discard them first",
4143                    winner.worktree.display()
4144                );
4145            }
4146            git::worktree_remove(&self.state.repo, &winner.worktree)
4147                .await
4148                .ok();
4149        }
4150        let fix_worktree = self.state.worktree_root().join("operator-fix");
4151        let fix_worktree_s = fix_worktree.to_string_lossy().to_string();
4152        git::git(
4153            &self.state.repo,
4154            &["worktree", "add", &fix_worktree_s, winner.branch.as_str()],
4155        )
4156        .await
4157        .with_context(|| format!("checking out `{}` for the fix", winner.branch))?;
4158        if !git::is_clean(&fix_worktree).await? {
4159            git::worktree_remove(&self.state.repo, &fix_worktree)
4160                .await
4161                .ok();
4162            bail!(
4163                "`{}` has uncommitted changes; refusing to start a fix on a dirty tree",
4164                winner.branch
4165            );
4166        }
4167        let repo_for_setup = self.state.repo.clone();
4168        let setup = match ensure_setup_config(&mut self.state, &repo_for_setup).await {
4169            Ok(()) => {
4170                worktree_setup::prepare(&self.state.config, &repo_for_setup, &fix_worktree).await
4171            }
4172            Err(e) => Err(e),
4173        };
4174        if let Err(e) = setup {
4175            git::worktree_remove(&self.state.repo, &fix_worktree)
4176                .await
4177                .ok();
4178            return Err(e);
4179        }
4180
4181        let run_id = self.state.id.clone();
4182        let prompts = self.state.config.prompts.clone();
4183        let language = self.state.config.graph.language.clone();
4184        let sessions = self.state.config.graph.sessions;
4185        let artifacts = agent::artifacts_dir(&self.state.dir());
4186        let finding_list: Vec<Finding> = self.state.operator_fixes[request_index]
4187            .findings
4188            .iter()
4189            .map(|f| Finding {
4190                id: f.id.clone(),
4191                severity: f.severity,
4192                file: f.file.clone(),
4193                line: f.line,
4194                title: f.title.clone(),
4195                detail: f.detail.clone(),
4196            })
4197            .collect();
4198        let fix_prompt = prompt::operator_fix(
4199            &self.state.instruction,
4200            &finding_list,
4201            reason,
4202            &stale_details,
4203            &head_at_request,
4204            &language,
4205        );
4206        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
4207        let (job, seat, out) = self
4208            .ask_fixer(&winner, "fix", None, |spec, seat| SeatJob {
4209                prompt: fix_prompt.clone(),
4210                spec,
4211                seat,
4212                cwd: fix_worktree.clone(),
4213                timeout,
4214                allow_write: true,
4215                sessions,
4216                artifacts: artifacts.clone(),
4217                stem: "operator-fix".to_owned(),
4218                handover: None,
4219            })
4220            .await;
4221        let agent_id = seat.agent.clone();
4222
4223        let mut fix = FixRecord {
4224            agent: agent_id,
4225            addressed: Vec::new(),
4226            rejected: Vec::new(),
4227            notes: String::new(),
4228            committed: false,
4229            failed: None,
4230            duration_ms: 0,
4231            continuation: None,
4232        };
4233        let mut final_seat = seat.clone();
4234        match out {
4235            AgentOutcome::Ok(o) => {
4236                fix.duration_ms = o.duration_ms;
4237                let parsed = verdict::extract_json::<FixReport>(&o.text);
4238                let incomplete_reason = match &parsed {
4239                    Ok(_) if has_unconfirmed_command(&o.commands) => Some(
4240                        "the reply parsed, but it reported a command whose own CLI \
4241                         never confirmed an exit status"
4242                            .to_owned(),
4243                    ),
4244                    Ok(_) => None,
4245                    Err(e) => Some(e.to_string()),
4246                };
4247                match incomplete_reason {
4248                    None => {
4249                        let report = parsed.expect("checked Ok above");
4250                        fix.addressed = report.addressed;
4251                        fix.rejected = report.rejected;
4252                        fix.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
4253                    }
4254                    Some(reason) => {
4255                        let (resumed_seat, resolved, failure, cont) = self
4256                            .continue_fix_report(seat, reason, &job, &prompts, &run_id, 0)
4257                            .await;
4258                        fix.duration_ms += cont.cumulative_wait_ms;
4259                        fix.continuation = Some(cont);
4260                        final_seat = resumed_seat;
4261                        match resolved {
4262                            Some(report) => {
4263                                fix.addressed = report.addressed;
4264                                fix.rejected = report.rejected;
4265                                fix.notes =
4266                                    blind::sanitize_prose(&report.notes, &self.state.config.blind);
4267                            }
4268                            None => fix.failed = failure,
4269                        }
4270                    }
4271                }
4272            }
4273            AgentOutcome::Dropped(o) => {
4274                fix.duration_ms = o.duration_ms;
4275                let why = o
4276                    .dropped
4277                    .as_ref()
4278                    .map(|d| d.why.as_str())
4279                    .unwrap_or("the CLI ended the stream without delivering its answer");
4280                fix.failed = Some(format!("the CLI dropped the stream ({why})"));
4281            }
4282            AgentOutcome::Quota(o) => {
4283                self.state.quota.push(QuotaLoss {
4284                    seat: final_seat.key.clone(),
4285                    node: "fix".to_owned(),
4286                    at: Timestamp::now(),
4287                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
4288                });
4289                fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
4290            }
4291            AgentOutcome::Failed(e) => fix.failed = Some(e),
4292        }
4293        if fix.continuation.is_none() {
4294            fix.continuation = Some(ContinuationRecord::not_needed());
4295        }
4296        self.state.seats.insert(final_seat.key.clone(), final_seat);
4297
4298        let rescue_message = format!(
4299            "magi: operator-selected fix ({}) (uncommitted work)",
4300            self.state.operator_fixes[request_index]
4301                .findings
4302                .iter()
4303                .map(|f| f.id.as_str())
4304                .collect::<Vec<_>>()
4305                .join(", ")
4306        );
4307        if let Ok(r) = git::rescue_commit(&fix_worktree, &rescue_message).await {
4308            self.state.note_withheld("fix", &r.withheld);
4309        }
4310        let after = git::rev_parse(&fix_worktree, "HEAD").await?;
4311        fix.committed = after != head_at_request;
4312        git::worktree_remove(&self.state.repo, &fix_worktree)
4313            .await
4314            .ok();
4315
4316        self.state.event(
4317            "fix",
4318            match &fix.failed {
4319                Some(reason) => format!(
4320                    "operator fix: adoption report was lost ({reason}); {}",
4321                    if fix.committed {
4322                        "committed"
4323                    } else {
4324                        "NO new commit"
4325                    }
4326                ),
4327                None => format!(
4328                    "operator fix: {} addressed, {} rejected, {}",
4329                    fix.addressed.len(),
4330                    fix.rejected.len(),
4331                    if fix.committed {
4332                        "committed"
4333                    } else {
4334                        "NO new commit"
4335                    }
4336                ),
4337            },
4338        );
4339
4340        // Every selected finding gets an outcome — never left `Pending` once
4341        // the fixer's own turn is over. A report that never came back at all
4342        // marks every one of them `Unreported`, not silently "not addressed":
4343        // quota, a dropped stream, or an exhausted continuation are gaps in
4344        // the report, not evidence about the finding itself (see [`SCHEMA`]'s
4345        // doc for schema 9 and [`OperatorFixOutcome::Unreported`]).
4346        for f in &mut self.state.operator_fixes[request_index].findings {
4347            f.outcome = if fix.failed.is_some() {
4348                OperatorFixOutcome::Unreported
4349            } else if fix.addressed.contains(&f.id) {
4350                OperatorFixOutcome::Addressed
4351            } else if let Some(r) = fix.rejected.iter().find(|r| r.id == f.id) {
4352                OperatorFixOutcome::Rejected { why: r.why.clone() }
4353            } else {
4354                OperatorFixOutcome::Unreported
4355            };
4356        }
4357
4358        let committed = fix.committed;
4359        if committed {
4360            self.state.operator_fixes[request_index].result_head = Some(after.clone());
4361        }
4362        self.state.operator_fixes[request_index].fix = Some(fix);
4363        // Saved again now that the fixer's own outcome is final, on top of
4364        // the save right after the request was first pushed above.
4365        self.state.save()?;
4366
4367        if committed {
4368            self.state.event(
4369                "fix",
4370                format!(
4371                    "operator fix committed {}; opening a follow-up review-only run",
4372                    short(&after)
4373                ),
4374            );
4375            // The operator asked for the fix, and the follow-up serves whatever
4376            // task the run it follows served.
4377            let origin =
4378                Origin::operator().serving(self.state.origin.as_ref().and_then(|o| o.task.clone()));
4379            match Self::review(
4380                &self.state.repo,
4381                &winner.branch,
4382                self.state.config.clone(),
4383                origin,
4384            )
4385            .await
4386            {
4387                Ok(mut follow_up) => {
4388                    follow_up.state.event(
4389                        "start",
4390                        format!(
4391                            "requested by an operator fix on run {} for finding(s) {}",
4392                            self.state.id,
4393                            self.state.operator_fixes[request_index]
4394                                .findings
4395                                .iter()
4396                                .map(|f| f.id.as_str())
4397                                .collect::<Vec<_>>()
4398                                .join(", "),
4399                        ),
4400                    );
4401                    follow_up.state.save()?;
4402                    let follow_up_id = follow_up.state.id.clone();
4403                    // The follow-up is a run like any other: it belongs to the
4404                    // task of the run it follows, or to one filed for it.
4405                    let adopted = match crate::direct::adopt(
4406                        &follow_up.state,
4407                        self.state.origin.as_ref().and_then(|o| o.task.as_deref()),
4408                    ) {
4409                        Ok(a) => a,
4410                        Err(e) => {
4411                            // An ownerless run must not spend agent calls; it
4412                            // stays saved, and `magi run --resume` adopts it.
4413                            self.state.event(
4414                                "fix",
4415                                format!(
4416                                    "follow-up review {follow_up_id} got no owning task and was not executed: {e:#}"
4417                                ),
4418                            );
4419                            self.state.save()?;
4420                            return Ok(());
4421                        }
4422                    };
4423                    let executed = follow_up.execute().await;
4424                    let failure = executed.as_ref().err().map(|e| format!("{e:#}"));
4425                    if let Some(a) = adopted {
4426                        a.finish(&follow_up.state, executed);
4427                    }
4428                    if let Some(e) = failure {
4429                        self.state.event(
4430                            "fix",
4431                            format!(
4432                                "follow-up review {follow_up_id} did not complete cleanly: {e:#}"
4433                            ),
4434                        );
4435                    }
4436                    self.state.operator_fixes[request_index].follow_up_review_run =
4437                        Some(follow_up_id);
4438                }
4439                Err(e) => {
4440                    self.state.event(
4441                        "fix",
4442                        format!("committed the fix but could not open a follow-up review: {e:#}"),
4443                    );
4444                }
4445            }
4446            self.state.save()?;
4447        }
4448
4449        Ok(())
4450    }
4451
4452    // --------------------------------------------------------------- review
4453
4454    /// Ask the fixer chain once for one fix call: the agents [`fixer::attempts`]
4455    /// names, in order, each at most once, moving on only when the call
4456    /// advances (an error, a quota hit or nothing usable - the same decision
4457    /// point `agent::chain_advances` is for the other chained roles).
4458    ///
4459    /// Each agent gets its own seat from `Runner::seat`: the same agent
4460    /// continues its conversation (the winner's own implementer seat when it
4461    /// is the winner's author, now that the competition is over), another
4462    /// takes a fresh one so the full prompt is sent again. A handover is
4463    /// recorded under `node`, which is what makes the fallback stick for the
4464    /// rest of the run (see `crate::fixer`). Only the last attempt's outcome
4465    /// is returned, so an exhausted chain reads exactly like a single failed
4466    /// fixer: one quota loss, the same wording, the same refund. An earlier
4467    /// attempt's edits are left in the tree and judged, with the final
4468    /// attempt's, by what git says afterwards.
4469    ///
4470    /// Returns the job that produced the outcome, for `continue_fix_report`.
4471    async fn ask_fixer(
4472        &mut self,
4473        winner: &Candidate,
4474        node: &'static str,
4475        round: Option<usize>,
4476        build: impl Fn(AgentSpec, SeatState) -> SeatJob,
4477    ) -> (SeatJob, SeatState, AgentOutcome) {
4478        let run_id = self.state.id.clone();
4479        let prompts = self.state.config.prompts.clone();
4480        let cache = self.state.config.cache_dir();
4481        let attempts = fixer::attempts(&self.state, &self.roles, winner);
4482        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
4483        let mut last = None;
4484        for (i, (spec, key)) in attempts.into_iter().enumerate() {
4485            let seat = self.seat(&key, &spec.id);
4486            let mut job = build(spec.clone(), seat);
4487            if i > 0 {
4488                job.stem = format!("{}-{}", job.stem, spec.id);
4489            }
4490            let ctx = WaveCtx {
4491                carry_seats: false,
4492                run: &run_id,
4493                node,
4494                prompts: &prompts,
4495                cache: cache.as_deref(),
4496                round,
4497            };
4498            let (seat, out) =
4499                run_one(job.clone(), Arc::clone(&self.sem), &ctx, &mut self.state, 0).await;
4500            let advances = match &out {
4501                AgentOutcome::Ok(o) => agent::output_advances(o),
4502                _ => true,
4503            };
4504            if let Some(next) = ids.get(i + 1)
4505                && advances
4506            {
4507                self.state.seats.insert(seat.key.clone(), seat.clone());
4508                let class =
4509                    FailClass::of(&out).unwrap_or_else(|| FailClass::Other("unusable".to_owned()));
4510                let reason = match &out {
4511                    AgentOutcome::Ok(_) => "nothing usable".to_owned(),
4512                    other => fail_reason(other),
4513                };
4514                record_handover(
4515                    &mut self.state,
4516                    node,
4517                    &seat.key,
4518                    &spec.id,
4519                    next,
4520                    &class,
4521                    &reason,
4522                );
4523                continue;
4524            }
4525            last = Some((job, seat, out));
4526            break;
4527        }
4528        last.expect("the fixer chain always has an entry")
4529    }
4530
4531    async fn review_loop(&mut self) -> Result<()> {
4532        // A base that would not rebase is a person's decision, not a review
4533        // round: nothing here would change the answer, and reviewers and a
4534        // fixer would be spending real budget on a tree that cannot land
4535        // regardless of what they find.
4536        if self
4537            .state
4538            .base_sync
4539            .as_ref()
4540            .is_some_and(|s| s.conflict.is_some())
4541        {
4542            return Ok(());
4543        }
4544        // Attribution for every agent this node spawns: `MAGI_RUN` lets a task the
4545        // agent files with `magi task add` name the run that paid for it. The
4546        // prompt overlay is cloned alongside it because the waves borrow it
4547        // while `self` is mutably borrowed by the node's own bookkeeping.
4548        let run_id = self.state.id.clone();
4549        let prompts = self.state.config.prompts.clone();
4550        let Some(winner) = self.state.winner().cloned() else {
4551            return Ok(());
4552        };
4553        let max_rounds = self.state.config.graph.review_rounds;
4554        // A clean round, an exhausted round budget, or a stalled tree (see
4555        // `STAGNANT_LIMIT`) are all already-decided conclusions the moment
4556        // they are recorded — recomputed here, not read off `status`, so a
4557        // reentry into a run that already stopped restates the identical
4558        // verdict instead of silently handing back whatever an earlier node
4559        // in this same walk clobbered `status` to (a solo-candidate
4560        // `judge`/`deliberate` skip rewrites it on every reentry). The loop
4561        // below runs an empty range once the budget is spent, and would
4562        // otherwise fall through without touching `status` at all.
4563        if let Some(status) = review_conclusion(&self.state.reviews, max_rounds) {
4564            // A reentry after a crash between the last round's save and
4565            // `stop_reviewing` reaches the hand-off here, not there.
4566            if status == RunStatus::Gating {
4567                self.record_contested_handoff();
4568            }
4569            self.state.status = status;
4570            self.state.save()?;
4571            return Ok(());
4572        }
4573        self.state.status = RunStatus::Reviewing;
4574        // A last recorded round whose own verification never resolved
4575        // (`ResourceBlocked` — the shared build cache, not the patch) is
4576        // never a concluded round, whatever the round budget says: starting
4577        // a fresh round on top of it would spend a whole new reviewer wave
4578        // re-reading an unchanged patch instead of just retrying the one
4579        // check that actually needs it, and once the budget is spent the
4580        // loop below has nothing left to do at all (its range is empty).
4581        // Retry that check directly instead, exactly the same retry
4582        // `stop_reviewing` already does for its own catch-up case.
4583        if self
4584            .state
4585            .reviews
4586            .last()
4587            .is_some_and(|r| r.e2e_status() == E2eStatus::ResourceBlocked)
4588        {
4589            let shell = self.state.config.shell();
4590            return self
4591                .stop_reviewing(
4592                    "the last round's own verification never resolved",
4593                    &shell,
4594                    &winner.worktree,
4595                )
4596                .await;
4597        }
4598
4599        let repo = self.state.repo.clone();
4600        let root = self.state.worktree_root();
4601        let language = self.state.config.graph.language.clone();
4602        let sessions = self.state.config.graph.sessions;
4603        let artifacts = agent::artifacts_dir(&self.state.dir());
4604        let base = self.landing_base();
4605        let base_short = short(&base);
4606        let reviewers = self.roles.reviewers.clone();
4607        let shell = self.state.config.shell();
4608
4609        for round in (self.state.reviews.len() + 1)..=max_rounds {
4610            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
4611            let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
4612            let stat = git::diff_stat(&winner.worktree, &base, "HEAD").await?;
4613            // The prior round's own record, already persisted — never a
4614            // hand-carried variable of just its failing output: that is
4615            // exactly what let a round's e2e result drift out of sync with
4616            // which commit it was actually about (see `SCHEMA`'s doc for
4617            // schema 8). Judged against `head`, the commit reviewers are
4618            // about to look at now, so the summary always reads as "an
4619            // earlier head" here — this round's own patch has not been
4620            // checked yet.
4621            let prev_verification = self
4622                .state
4623                .reviews
4624                .last()
4625                .and_then(|r| r.verification_summary(&head));
4626
4627            // Each reviewer gets its own detached checkout of exactly this
4628            // commit: nobody can perturb the winner's tree, and the fixer can
4629            // keep working without racing a reviewer.
4630            let mut jobs = Vec::new();
4631            for (r, spec) in reviewers.iter().cloned().enumerate() {
4632                let wt = root.join(format!("review-{}", r + 1));
4633                // `clean -fdx` in a reset wipes setup's products, so every
4634                // round sets the seat up again.
4635                if wt.exists() {
4636                    git::reset_detached(&wt, &head).await?;
4637                } else {
4638                    git::worktree_add_detached(&repo, &wt, &head).await?;
4639                }
4640                self.setup_seat_worktree(&repo, &wt, false).await?;
4641                let seat_key = format!("review-{}", r + 1);
4642                // The seat starts the round on whoever answered it last, not
4643                // on the agent the spec names, so a failure is not re-paid.
4644                let spec = pick_start_spec(
4645                    &self.roles.reviewer_roster,
4646                    spec,
4647                    self.state.seat_history.get(&seat_key),
4648                );
4649                let seat = self.seat(&seat_key, &spec.id);
4650                jobs.push(SeatJob {
4651                    prompt: prompt::review(&prompt::ReviewCtx {
4652                        instruction: &self.state.instruction,
4653                        branch: &winner.branch,
4654                        base_short: &base_short,
4655                        stat: &stat,
4656                        patch: &patch,
4657                        verification: prev_verification.as_ref(),
4658                        reviewers: reviewers.len(),
4659                        round,
4660                        rounds: max_rounds,
4661                        // A review-only run has no rankings, so nothing
4662                        // competed for this patch and the reviewer is told so.
4663                        competed: self.state.tally.as_ref().is_some_and(|t| t.rankings > 0),
4664                        lens: Lens::for_seat(r),
4665                        language: &language,
4666                    }),
4667                    spec,
4668                    seat,
4669                    cwd: wt,
4670                    timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4671                    allow_write: false,
4672                    sessions,
4673                    artifacts: artifacts.clone(),
4674                    stem: format!("review-{round}-{}", r + 1),
4675                    handover: None,
4676                });
4677            }
4678
4679            self.state.event(
4680                "review",
4681                format!(
4682                    "round {round}: {} reviewers on {}",
4683                    jobs.len(),
4684                    short(&head)
4685                ),
4686            );
4687            let mut quota_losses = Vec::new();
4688            let review_retries = self.state.config.graph.retries;
4689            let review_cache = self.state.config.cache_dir();
4690            let ctx = WaveCtx {
4691                carry_seats: true,
4692                run: &run_id,
4693                node: "review",
4694                prompts: &prompts,
4695                cache: review_cache.as_deref(),
4696                round: Some(round),
4697            };
4698            let results = ask_json_wave::<Review>(
4699                jobs,
4700                Arc::clone(&self.sem),
4701                review_retries,
4702                &self.roles.reviewer_roster,
4703                &ctx,
4704                &mut quota_losses,
4705                &mut self.state,
4706                &|_: &Review| Ok(()),
4707            )
4708            .await;
4709            // Counted before the move below: how many of *this* round's
4710            // reviewer seats were lost to their own rate limit, as opposed to
4711            // a crash, a timeout, or unparsable output — see `round_is_clean`.
4712            let round_quota_missing = quota_losses.len();
4713            self.state.quota.extend(quota_losses);
4714
4715            let mut records = Vec::new();
4716            let mut all_findings = Vec::new();
4717            for (r, (seat, res, attempts)) in results.into_iter().enumerate() {
4718                let agent_id = seat.agent.clone();
4719                self.state.seats.insert(seat.key.clone(), seat);
4720                let mut record = ReviewRecord {
4721                    reviewer: r + 1,
4722                    agent: agent_id,
4723                    summary: String::new(),
4724                    findings: Vec::new(),
4725                    vote: None,
4726                    failed: None,
4727                    duration_ms: 0,
4728                    // Set for both outcomes: `failed: Some(_)` with
4729                    // `attempts > 0` is a seat every retry still lost, not a
4730                    // recovered one — only `failed: None` with `attempts > 0`
4731                    // reads as "answered after a nudge" (see this field's own
4732                    // doc).
4733                    attempts,
4734                };
4735                match res {
4736                    Ok((review, out)) => {
4737                        // Sanitized here, at the point every other piece of
4738                        // agent prose in this file is (candidate summaries,
4739                        // deliberation turns, vote reasons): a reviewer's own
4740                        // words are the one thing about it that could name
4741                        // it, and reconsideration below broadcasts this same
4742                        // summary and these same findings to every other
4743                        // seat on the panel.
4744                        record.summary =
4745                            blind::sanitize_prose(&review.summary, &self.state.config.blind);
4746                        record.vote = Some(review.vote);
4747                        record.duration_ms = out.duration_ms;
4748                        for (n, mut f) in review.findings.into_iter().enumerate() {
4749                            // ids are magi's, never the agent's: the fixer's
4750                            // adoption report is keyed by them.
4751                            f.id = format!("R{round}-{}-{}", r + 1, n + 1);
4752                            f.title = blind::sanitize_prose(&f.title, &self.state.config.blind);
4753                            f.detail = blind::sanitize_prose(&f.detail, &self.state.config.blind);
4754                            // `file` is agent-supplied prose too, never
4755                            // checked against the real tree — the same
4756                            // exposure `title`/`detail` above have, just in
4757                            // a field easy to forget because it looks like a
4758                            // path rather than free text.
4759                            f.file = f
4760                                .file
4761                                .map(|file| blind::sanitize_prose(&file, &self.state.config.blind));
4762                            all_findings.push(f.clone());
4763                            record.findings.push(f);
4764                        }
4765                        self.state.event(
4766                            "review",
4767                            format!(
4768                                "round {round}: reviewer {} voted {} with {} finding(s)",
4769                                r + 1,
4770                                review.vote.label(),
4771                                record.findings.len()
4772                            ),
4773                        );
4774                    }
4775                    Err(e) => {
4776                        record.failed = Some(e.to_string());
4777                        self.state.event(
4778                            "review",
4779                            format!("round {round}: reviewer {} produced nothing: {e}", r + 1),
4780                        );
4781                    }
4782                }
4783                records.push(record);
4784            }
4785
4786            // Tally the round's votes and, if they split, spend the one
4787            // round of reconsideration the split -> deliberate -> revote
4788            // shape `judge`/`vote` use for the panel, sized down to what a
4789            // read-only review round can afford: one round, and a revote
4790            // rather than an argument, because the panel already wrote its
4791            // reasoning down as findings the first time around.
4792            let initial_votes: Vec<ReviewVote> = records.iter().filter_map(|r| r.vote).collect();
4793            let vote_split =
4794                initial_votes.len() > 1 && !initial_votes.iter().all(|v| *v == initial_votes[0]);
4795            let mut reconsideration: Vec<ReviewRevoteRecord> = Vec::new();
4796            if vote_split {
4797                self.state.event(
4798                    "review",
4799                    format!(
4800                        "round {round}: votes split ({}) — one round of reconsideration",
4801                        initial_votes
4802                            .iter()
4803                            .map(|v| v.label())
4804                            .collect::<Vec<_>>()
4805                            .join(", ")
4806                    ),
4807                );
4808                // Seats read every seat's findings and votes, still numbered
4809                // and never named — the same anonymity `review` itself keeps.
4810                let panel: Vec<ReviewSeatReport<'_>> = records
4811                    .iter()
4812                    .filter_map(|r| {
4813                        r.vote.map(|vote| ReviewSeatReport {
4814                            reviewer: r.reviewer,
4815                            vote,
4816                            summary: &r.summary,
4817                            findings: &r.findings,
4818                        })
4819                    })
4820                    .collect();
4821
4822                let mut jobs = Vec::new();
4823                let mut seats_at = Vec::new();
4824                for (r, spec) in reviewers.iter().cloned().enumerate() {
4825                    // A seat with no initial vote has nothing to reconsider
4826                    // from and stays absent, the same as it stayed absent
4827                    // from `panel` above.
4828                    if records[r].vote.is_none() {
4829                        continue;
4830                    }
4831                    let wt = root.join(format!("review-{}", r + 1));
4832                    let seat_key = format!("review-{}", r + 1);
4833                    let spec = self.occupant(&seat_key, spec);
4834                    let seat = self.seat(&seat_key, &spec.id);
4835                    // A seat with no live session has already forgotten the
4836                    // initial review's prompt — restate the patch it is
4837                    // voting on, the same as `deliberate`/`vote` do for a
4838                    // judge in the same position.
4839                    // The panel already carries this seat's own review and
4840                    // vote, so restating the patch makes the prompt whole for
4841                    // a seat handed to another agent.
4842                    let build = |with_patch: bool| {
4843                        prompt::review_reconsider(&ReviewReconsiderCtx {
4844                            instruction: &self.state.instruction,
4845                            reviewer: r + 1,
4846                            lens: Lens::for_seat(r),
4847                            panel: &panel,
4848                            patch: with_patch.then_some(ReviewPatch {
4849                                branch: &winner.branch,
4850                                base_short: &base_short,
4851                                stat: &stat,
4852                                patch: &patch,
4853                            }),
4854                            round,
4855                            rounds: max_rounds,
4856                            language: &language,
4857                        })
4858                    };
4859                    let full = build(true);
4860                    let prompt = if has_context(&spec, &seat, sessions) {
4861                        build(false)
4862                    } else {
4863                        full.clone()
4864                    };
4865                    jobs.push(SeatJob {
4866                        prompt,
4867                        spec,
4868                        seat,
4869                        cwd: wt,
4870                        timeout: Duration::from_secs(self.state.config.graph.timeout_review),
4871                        allow_write: false,
4872                        sessions,
4873                        artifacts: artifacts.clone(),
4874                        stem: format!("review-{round}-reconsider-{}", r + 1),
4875                        handover: Some(full),
4876                    });
4877                    seats_at.push(r);
4878                }
4879
4880                let mut recon_quota_losses = Vec::new();
4881                let recon_cache = self.state.config.cache_dir();
4882                let recon_ctx = WaveCtx {
4883                    carry_seats: true,
4884                    run: &run_id,
4885                    node: "review",
4886                    prompts: &prompts,
4887                    cache: recon_cache.as_deref(),
4888                    round: Some(round),
4889                };
4890                let recon_results = ask_json_wave::<ReviewRevote>(
4891                    jobs,
4892                    Arc::clone(&self.sem),
4893                    review_retries,
4894                    &self.roles.reviewer_roster,
4895                    &recon_ctx,
4896                    &mut recon_quota_losses,
4897                    &mut self.state,
4898                    &|_: &ReviewRevote| Ok(()),
4899                )
4900                .await;
4901                self.state.quota.extend(recon_quota_losses);
4902
4903                for (&r, (seat, res, _attempts)) in seats_at.iter().zip(recon_results) {
4904                    let agent_id = seat.agent.clone();
4905                    self.state.seats.insert(seat.key.clone(), seat);
4906                    let mut rec = ReviewRevoteRecord {
4907                        reviewer: r + 1,
4908                        agent: agent_id,
4909                        vote: None,
4910                        reason: String::new(),
4911                        failed: None,
4912                    };
4913                    match res {
4914                        Ok((rv, _)) => {
4915                            rec.vote = Some(rv.vote);
4916                            rec.reason =
4917                                blind::sanitize_prose(&rv.reason, &self.state.config.blind);
4918                            self.state.event(
4919                                "review",
4920                                format!(
4921                                    "round {round}: reviewer {} revoted {}",
4922                                    r + 1,
4923                                    rv.vote.label()
4924                                ),
4925                            );
4926                        }
4927                        Err(e) => {
4928                            rec.failed = Some(e.to_string());
4929                            self.state.event(
4930                                "review",
4931                                format!("round {round}: reviewer {} did not revote: {e}", r + 1),
4932                            );
4933                        }
4934                    }
4935                    reconsideration.push(rec);
4936                }
4937            } else if initial_votes.len() > 1 {
4938                self.state.event(
4939                    "review",
4940                    format!(
4941                        "round {round}: votes agreed ({}) — no reconsideration",
4942                        initial_votes[0].label()
4943                    ),
4944                );
4945            }
4946
4947            let blocking = all_findings.iter().filter(|f| f.severity.blocks()).count();
4948            let verify_timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
4949            // A round that already has a blocking finding and a round left to
4950            // try is going back to the fixer no matter what `verify.e2e`
4951            // says, so running it first only spends the loop's slowest step
4952            // (minutes, for a Rust repo's full test suite) on a head about
4953            // to be rewritten. Deferred, never skipped: `verify.e2e` still
4954            // runs once a round has no blocking findings left (see
4955            // `round_is_clean`, which a deferred — empty — `e2e` can never
4956            // satisfy since `blocking` is nonzero whenever this branch is
4957            // taken), and `stop_reviewing` forces a real run before it will
4958            // ever read a deferred round as green.
4959            let defer_e2e =
4960                blocking > 0 && round < max_rounds && !self.state.config.graph.e2e_every_round;
4961            let (e2e, verify_retried, e2e_deferred, e2e_defer_reason) = if defer_e2e {
4962                let reason =
4963                    format!("{blocking} blocking finding(s) already required a fix this round");
4964                self.state.event(
4965                    "verify",
4966                    format!(
4967                        "round {round}: {reason} — e2e deferred to the fixer (reviewed head \
4968                         {}); it will run once a round has none left",
4969                        short(&head)
4970                    ),
4971                );
4972                (Vec::new(), false, true, Some(reason))
4973            } else {
4974                let e2e_commands = self.state.config.verify.e2e.clone();
4975                let cache_dir = self.state.config.cache_dir();
4976                let context = format!("round {round}");
4977                let (e2e, verify_retried) = with_cache_lease(
4978                    &mut self.state,
4979                    cache_dir.as_deref(),
4980                    "e2e",
4981                    "e2e",
4982                    &winner.worktree,
4983                    &head,
4984                    verify_timeout,
4985                    &context,
4986                    |state, budget| {
4987                        let shell = shell.clone();
4988                        let e2e_commands = e2e_commands.clone();
4989                        let worktree = winner.worktree.clone();
4990                        let context = context.clone();
4991                        async move {
4992                            run_e2e_with_retry(
4993                                state,
4994                                &shell,
4995                                &e2e_commands,
4996                                &worktree,
4997                                budget,
4998                                &context,
4999                            )
5000                            .await
5001                        }
5002                    },
5003                )
5004                .await;
5005                (e2e, verify_retried, false, None)
5006            };
5007
5008            let expected = records.len();
5009            let answered = records.iter().filter(|r| r.failed.is_none()).count();
5010            let incomplete = answered < expected;
5011            let e2e_ok = e2e.iter().all(CommandOutcome::ok);
5012            let policy = self.state.config.graph.incomplete_review;
5013            let clean = round_is_clean(
5014                blocking,
5015                e2e_ok,
5016                answered,
5017                expected,
5018                round_quota_missing,
5019                policy,
5020            );
5021
5022            let mut round_record = ReviewRound {
5023                round,
5024                head: head.clone(),
5025                verified_head: None,
5026                verified_at: None,
5027                reviews: records,
5028                e2e,
5029                verify_retried,
5030                e2e_deferred,
5031                e2e_defer_reason,
5032                fix: None,
5033                blocking,
5034                answered,
5035                expected,
5036                clean,
5037                progressed: false,
5038                vote_split,
5039                reconsideration,
5040                verdict: None,
5041            };
5042            // The final vote per seat is its revote where reconsideration
5043            // ran and answered, its initial vote otherwise — the same
5044            // fallback `tally` uses for a judge whose private vote failed.
5045            round_record.verdict = ReviewVote::worst(
5046                round_record
5047                    .final_votes()
5048                    .into_iter()
5049                    .map(|(_, _, vote)| vote),
5050            );
5051            // Which commit and when magi actually attempted to check —
5052            // known the moment a command was dispatched against `head`,
5053            // whether or not it finished: a resource-blocked attempt still
5054            // targeted a specific commit at a specific time, and leaving
5055            // that unrecorded is exactly what made `verification_summary`
5056            // report a fresh attempt as "commit unknown ... recorded before
5057            // this was tracked", indistinguishable from a genuinely old,
5058            // untracked record. Only a deferred or unconfigured round never
5059            // ran at all and has nothing to record — see
5060            // `ReviewRound::verified_head`'s own doc.
5061            if !matches!(
5062                round_record.e2e_status(),
5063                E2eStatus::Deferred | E2eStatus::NotConfigured
5064            ) {
5065                round_record.verified_head = Some(head.clone());
5066                round_record.verified_at = Some(Timestamp::now());
5067            }
5068            let this_round_verification = round_record.verification_summary(&head);
5069
5070            if incomplete {
5071                let missing: Vec<String> = round_record
5072                    .reviews
5073                    .iter()
5074                    .filter(|r| r.failed.is_some())
5075                    .map(|r| format!("review-{}", r.reviewer))
5076                    .collect();
5077                self.state.event(
5078                    "review",
5079                    format!(
5080                        "round {round}: {answered}/{expected} reviewer(s) answered ({} never answered)",
5081                        missing.join(", ")
5082                    ),
5083                );
5084            }
5085
5086            if clean {
5087                self.state.event(
5088                    "review",
5089                    if incomplete && policy == IncompleteReviewPolicy::Warn {
5090                        format!(
5091                            "round {round}: clean (warn policy, incomplete panel) — no \
5092                             blocking findings from the seats that answered, verification green"
5093                        )
5094                    } else if incomplete {
5095                        format!(
5096                            "round {round}: clean ({} rate-limited reviewer(s) excluded from \
5097                             quorum) — no blocking findings from the seats that answered, \
5098                             verification green",
5099                            expected - answered
5100                        )
5101                    } else {
5102                        format!("round {round}: clean — no blocking findings, verification green")
5103                    },
5104                );
5105                self.state.reviews.push(round_record);
5106                self.state.status = RunStatus::Gating;
5107                self.state.save()?;
5108                return Ok(());
5109            }
5110
5111            // Nothing was raised and verification passed, but not every seat
5112            // answered and `round_is_clean` still refused to call it clean —
5113            // either a seat is missing for a reason other than its own quota
5114            // (a crash, a timeout, unparsable output — worth another try), or
5115            // every seat that could have answered lost its quota and nobody
5116            // is left to decide on: re-review rather than send the fixer
5117            // after a round with nothing to fix.
5118            if incomplete && blocking == 0 && e2e_ok {
5119                self.state.reviews.push(round_record);
5120                self.state.save()?;
5121                if round == max_rounds {
5122                    self.state.status = RunStatus::Blocked;
5123                    self.state.event(
5124                        "review",
5125                        format!(
5126                            "{} reviewer seat(s) never answered after {max_rounds} rounds; \
5127                             refusing to call it clean",
5128                            expected - answered
5129                        ),
5130                    );
5131                    return Ok(());
5132                }
5133                continue;
5134            }
5135
5136            // Nothing for the fixer to act on (`blocking == 0`) and the only
5137            // reason this round is not clean is that magi itself never got
5138            // a command to run — the shared build cache, not the patch (see
5139            // `CommandOutcome::resource_blocked`'s own doc). Sending that to
5140            // the fixer would invite a change to appease contention that has
5141            // nothing to do with the diff, and would leave this attempt
5142            // sitting in the next round's prompt as if it were about an
5143            // earlier, superseded commit rather than what it actually is:
5144            // the same head, still waiting to be checked. Wait for it the
5145            // same way the final round's own contention is already handled,
5146            // whatever round this happens to be.
5147            if blocking == 0 && round_record.e2e_status() == E2eStatus::ResourceBlocked {
5148                self.state.reviews.push(round_record);
5149                return self
5150                    .stop_reviewing(
5151                        "the round's own verification could not run",
5152                        &shell,
5153                        &winner.worktree,
5154                    )
5155                    .await;
5156            }
5157
5158            if round == max_rounds {
5159                self.state.reviews.push(round_record);
5160                return self
5161                    .stop_reviewing(
5162                        &format!(
5163                            "{blocking} blocking finding(s) still open after {max_rounds} round(s)"
5164                        ),
5165                        &shell,
5166                        &winner.worktree,
5167                    )
5168                    .await;
5169            }
5170
5171            // Fix. The winner's own implementer seat continues its conversation:
5172            // the competition is over, so context is pure benefit now.
5173            let blocking_findings: Vec<_> = all_findings
5174                .iter()
5175                .filter(|f| f.severity.blocks())
5176                .cloned()
5177                .collect();
5178            let fix_prompt = prompt::fix(
5179                &self.state.instruction,
5180                &blocking_findings,
5181                this_round_verification.as_ref(),
5182                round,
5183                max_rounds,
5184                &language,
5185            );
5186            let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5187            let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5188            let (job, seat, out) = self
5189                .ask_fixer(&winner, "fix", Some(round), |spec, seat| SeatJob {
5190                    prompt: fix_prompt.clone(),
5191                    spec,
5192                    seat,
5193                    cwd: winner.worktree.clone(),
5194                    timeout,
5195                    allow_write: true,
5196                    sessions,
5197                    artifacts: artifacts.clone(),
5198                    stem: format!("fix-{round}"),
5199                    handover: None,
5200                })
5201                .await;
5202            let agent_id = seat.agent.clone();
5203
5204            let mut fix = FixRecord {
5205                agent: agent_id,
5206                addressed: Vec::new(),
5207                rejected: Vec::new(),
5208                notes: String::new(),
5209                committed: false,
5210                failed: None,
5211                duration_ms: 0,
5212                continuation: None,
5213            };
5214            let mut continuation = ContinuationRecord::not_needed();
5215            let mut final_seat = seat.clone();
5216            match out {
5217                AgentOutcome::Ok(o) => {
5218                    fix.duration_ms = o.duration_ms;
5219                    let parsed = verdict::extract_json::<FixReport>(&o.text);
5220                    // A parsed report standing next to a command this same
5221                    // reply's own CLI never confirmed the exit status of is
5222                    // not a resolved answer — the identical `CommandEvidence`
5223                    // `state.jobs` renders, read here instead of only on
5224                    // display, per the completion judgment and the shown
5225                    // record needing to agree.
5226                    let incomplete_reason = match &parsed {
5227                        Ok(_) if has_unconfirmed_command(&o.commands) => Some(
5228                            "the reply parsed, but it reported a command whose own CLI never \
5229                             confirmed an exit status"
5230                                .to_owned(),
5231                        ),
5232                        Ok(_) => None,
5233                        Err(e) => Some(e.to_string()),
5234                    };
5235                    match incomplete_reason {
5236                        None => {
5237                            let report = parsed.expect("checked Ok above");
5238                            fix.addressed = report.addressed;
5239                            fix.rejected = report.rejected;
5240                            fix.notes =
5241                                blind::sanitize_prose(&report.notes, &self.state.config.blind);
5242                        }
5243                        Some(reason) => {
5244                            let (resumed_seat, resolved, failure, cont) = self
5245                                .continue_fix_report(seat, reason, &job, &prompts, &run_id, round)
5246                                .await;
5247                            fix.duration_ms += cont.cumulative_wait_ms;
5248                            continuation = cont;
5249                            final_seat = resumed_seat;
5250                            match resolved {
5251                                Some(report) => {
5252                                    fix.addressed = report.addressed;
5253                                    fix.rejected = report.rejected;
5254                                    fix.notes = blind::sanitize_prose(
5255                                        &report.notes,
5256                                        &self.state.config.blind,
5257                                    );
5258                                }
5259                                None => fix.failed = failure,
5260                            }
5261                        }
5262                    }
5263                }
5264                // The CLI's raw error JSON is not a fix report to parse.
5265                AgentOutcome::Dropped(o) => {
5266                    fix.duration_ms = o.duration_ms;
5267                    let why = o
5268                        .dropped
5269                        .as_ref()
5270                        .map(|d| d.why.as_str())
5271                        .unwrap_or("the CLI ended the stream without delivering its answer");
5272                    fix.failed = Some(format!("the CLI dropped the stream ({why})"));
5273                }
5274                AgentOutcome::Quota(o) => {
5275                    self.state.quota.push(QuotaLoss {
5276                        seat: final_seat.key.clone(),
5277                        node: "fix".to_owned(),
5278                        at: Timestamp::now(),
5279                        reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5280                    });
5281                    fix.failed = Some("rate limited (quota); fixer could not run".to_owned());
5282                }
5283                AgentOutcome::Failed(e) => fix.failed = Some(e),
5284            }
5285            fix.continuation = Some(continuation);
5286            self.state.seats.insert(final_seat.key.clone(), final_seat);
5287            if let Ok(r) = git::rescue_commit(
5288                &winner.worktree,
5289                &format!("magi: review round {round} fixes (uncommitted work)"),
5290            )
5291            .await
5292            {
5293                self.state.note_withheld("fix", &r.withheld);
5294            }
5295            let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5296            fix.committed = after != before;
5297            // Judged by what `git` says moved against base, never by the
5298            // fixer's own `addressed`/`rejected` count — see
5299            // `ReviewRound::progressed`. Propagated with `?`, the same as the
5300            // `patch` snapshot above: swallowing this error would default
5301            // `diff_after` to empty, which almost always differs from a
5302            // non-empty `patch` and reads as "progressed" — exactly backwards
5303            // for a `git` failure the stagnation check cannot see through.
5304            let diff_after = git::diff(&winner.worktree, &base, "HEAD").await?;
5305            let progressed = diff_after != patch;
5306            let commit_note = if fix.committed {
5307                "committed"
5308            } else {
5309                "NO new commit"
5310            };
5311            let tree_note = if progressed {
5312                "changed vs base"
5313            } else {
5314                "unchanged vs base"
5315            };
5316            self.state.event(
5317                "fix",
5318                match &fix.failed {
5319                    // Distinct on purpose from "0 addressed, 0 rejected": the
5320                    // fixer's own diff still landed (blocking counts do keep
5321                    // falling round over round), only its adoption report did
5322                    // not come back, so this must never read like every
5323                    // finding was reviewed and declined.
5324                    Some(reason) => {
5325                        format!(
5326                            "round {round}: fixer's adoption report was lost ({reason}); \
5327                             {commit_note}, tree {tree_note}"
5328                        )
5329                    }
5330                    None => format!(
5331                        "round {round}: {} addressed, {} rejected, {commit_note}, tree \
5332                         {tree_note}{}",
5333                        fix.addressed.len(),
5334                        fix.rejected.len(),
5335                        if continuation.outcome == ContinuationOutcome::Resumed {
5336                            format!(
5337                                " (adoption report recovered after {} continuation(s))",
5338                                continuation.attempts
5339                            )
5340                        } else {
5341                            String::new()
5342                        },
5343                    ),
5344                },
5345            );
5346            round_record.fix = Some(fix);
5347            round_record.progressed = progressed;
5348            self.state.reviews.push(round_record);
5349            self.state.save()?;
5350
5351            // The fixer's own report never came back this round, even after
5352            // `continue_fix_report`'s own budget was spent on it — not an
5353            // ordinary "no report" (dropped stream, quota, plain failure),
5354            // which already reads that way and is left to the existing round
5355            // budget. Stopping here, rather than opening another round, is
5356            // what keeps a next reviewer/fixer wave from ever being
5357            // dispatched onto `winner.worktree` while whatever the seat's
5358            // last call may still have running there is unaccounted for: no
5359            // process liveness check exists (and none is being added — see
5360            // AGENTS.md/this task's own scope), so the only way to honour
5361            // "nothing starts before a valid report returns" is to not start
5362            // anything further on this worktree from this run at all.
5363            if matches!(
5364                continuation.outcome,
5365                ContinuationOutcome::Exhausted
5366                    | ContinuationOutcome::QuotaLost
5367                    | ContinuationOutcome::NoSession
5368            ) {
5369                return self
5370                    .stop_reviewing(
5371                        "the fixer's adoption report never came back, even after resuming its \
5372                         own seat; refusing to start another round against the same worktree \
5373                         while that is unresolved",
5374                        &shell,
5375                        &winner.worktree,
5376                    )
5377                    .await;
5378            }
5379
5380            let streak = self
5381                .state
5382                .reviews
5383                .iter()
5384                .rev()
5385                .take_while(|r| !r.progressed)
5386                .count();
5387            if streak >= STAGNANT_LIMIT {
5388                return self
5389                    .stop_reviewing(
5390                        &format!(
5391                            "the tree has not moved against base for {streak} round(s) in a row"
5392                        ),
5393                        &shell,
5394                        &winner.worktree,
5395                    )
5396                    .await;
5397            }
5398        }
5399        Ok(())
5400    }
5401
5402    /// Decide, from the last recorded round's own verification, whether
5403    /// stopping the review loop is a hand-off or a genuine block.
5404    ///
5405    /// Called once the loop has given up trying — the round budget is spent,
5406    /// or the tree stopped moving (see [`STAGNANT_LIMIT`]) — with blocking
5407    /// findings still open, never while a round is still clean or the
5408    /// incomplete-panel case handled inline above. Gate and e2e are facts
5409    /// about the tree; a lingering review finding is an opinion, and this
5410    /// workload's own `magi stats` puts reviewer precision low enough
5411    /// (12-33%, 0.18-0.29 adopted per round) that a panel of open findings
5412    /// must not by itself stand between a green, verified change and the
5413    /// human who decides what to do with it. A red e2e is not an opinion, so
5414    /// that case still blocks, with the failing command and a tail of its
5415    /// output recorded here rather than left in `run.json` for someone to go
5416    /// find.
5417    ///
5418    /// A round that deferred its own e2e (see [`Config::graph`]'s
5419    /// `e2e_every_round`) is never read as that green: its `e2e` is empty
5420    /// only because nothing ran, and treating an empty list as a passing one
5421    /// here is exactly the "deferred painted green" bug this function exists
5422    /// to not have. When the last round's own verification never resolved —
5423    /// deferred on purpose, or a real attempt the shared build cache blocked
5424    /// — this makes (or retries) the real run, on the actual worktree this
5425    /// loop is about to stop touching, before deciding anything. A
5426    /// resource-blocked attempt is likewise never read as either green or
5427    /// red: it is evidence about the machine, not the patch (see
5428    /// [`CommandOutcome::resource_blocked`]'s own doc), so a persistently
5429    /// blocked cache leaves this call without deciding rather than guessing
5430    /// — the caller retries on a later reentry.
5431    /// Record, once, that the review loop handed off over a blocking finding
5432    /// a reviewer rejected on (see [`ReviewRound::contested_handoff`]), so
5433    /// `land` asks the owner even with `land_approval` off. Called from every
5434    /// path that concludes `Gating`; a reentry keeps the first record.
5435    fn record_contested_handoff(&mut self) {
5436        if self.state.contested_handoff.is_some() {
5437            return;
5438        }
5439        let Some(contested) = self
5440            .state
5441            .reviews
5442            .last()
5443            .and_then(ReviewRound::contested_handoff)
5444        else {
5445            return;
5446        };
5447        self.state.event(
5448            "review",
5449            format!(
5450                "{} blocking finding(s) open and {} reviewer(s) rejecting — the merge will \
5451                 wait for the owner's approval",
5452                contested.findings.len(),
5453                contested.rejecters.len()
5454            ),
5455        );
5456        self.state.contested_handoff = Some(contested);
5457    }
5458
5459    async fn stop_reviewing(&mut self, why: &str, shell: &[String], worktree: &Path) -> Result<()> {
5460        let round_idx = self.state.reviews.len() - 1;
5461        // A deferred round and a resource-blocked one are the same shape
5462        // here: neither has a real result yet, and both get one more
5463        // attempt. Read off `e2e_status` — the single source for this —
5464        // rather than `e2e.is_empty()` alone, so a resource-blocked attempt
5465        // (whose `e2e` is *not* empty; see `CommandOutcome::resource_blocked`)
5466        // still retries instead of being read as a settled result the
5467        // instant it stops being empty.
5468        let needs_catchup_run = matches!(
5469            self.state.reviews[round_idx].e2e_status(),
5470            E2eStatus::Deferred | E2eStatus::ResourceBlocked
5471        );
5472        if needs_catchup_run {
5473            let round = self.state.reviews[round_idx].round;
5474            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5475            let commands = self.state.config.verify.e2e.clone();
5476            let attempted_head = git::rev_parse(worktree, "HEAD").await?;
5477            let cache_dir = self.state.config.cache_dir();
5478            let context = format!(
5479                "round {round}: verification unresolved, catching up before the final decision"
5480            );
5481            let (outcomes, verify_retried) = with_cache_lease(
5482                &mut self.state,
5483                cache_dir.as_deref(),
5484                "e2e",
5485                "e2e",
5486                worktree,
5487                &attempted_head,
5488                timeout,
5489                &context,
5490                |state, budget| {
5491                    let shell = shell.to_vec();
5492                    let commands = commands.clone();
5493                    let context = context.clone();
5494                    async move {
5495                        run_e2e_with_retry(state, &shell, &commands, worktree, budget, &context)
5496                            .await
5497                    }
5498                },
5499            )
5500            .await;
5501            let last = &mut self.state.reviews[round_idx];
5502            last.e2e = outcomes;
5503            last.verify_retried = verify_retried;
5504            // Always the commit and time this attempt actually targeted,
5505            // whether or not it happens to equal the reviewed `head` and
5506            // whether or not a command finished — see
5507            // `ReviewRound::verified_head`'s own doc. A still-inconclusive
5508            // attempt is recorded too, so a later reader sees "attempted
5509            // again at T2" rather than silence.
5510            last.verified_head = Some(attempted_head);
5511            last.verified_at = Some(Timestamp::now());
5512            if verify_inconclusive(&last.e2e) {
5513                // Still not a real result: `e2e_deferred` is left exactly
5514                // as it was, so `needs_catchup_run` above reads
5515                // `ResourceBlocked` (via `e2e_status`, which checks
5516                // `resource_blocked` before `e2e_deferred`) and retries
5517                // again on the next reentry, rather than recording
5518                // contention as a red e2e and blocking the run on it.
5519                self.state.save()?;
5520                return Ok(());
5521            }
5522            last.e2e_deferred = false;
5523        }
5524        let last = &self.state.reviews[round_idx];
5525        let open: usize = last.reviews.iter().map(|r| r.findings.len()).sum();
5526
5527        match last.e2e_status() {
5528            E2eStatus::Failed => {
5529                let red: Vec<String> = last
5530                    .e2e
5531                    .iter()
5532                    .filter(|o| !o.ok())
5533                    .map(|o| {
5534                        format!(
5535                            "`{}` -> {:?}\n{}",
5536                            o.command,
5537                            o.code,
5538                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5539                        )
5540                    })
5541                    .collect();
5542                self.state
5543                    .event("review", format!("{why}; e2e failed:\n{}", red.join("\n")));
5544                self.state.status = RunStatus::Blocked;
5545            }
5546            // `needs_catchup_run` above already retried once this call; if
5547            // it is still blocked, this is magi's own admission it could
5548            // not get a command to run, never a verdict on the patch — the
5549            // run is left exactly where a later reentry can retry again.
5550            E2eStatus::ResourceBlocked => {
5551                self.state.event(
5552                    "review",
5553                    format!(
5554                        "{why}; e2e could not run (shared build cache unavailable); not \
5555                         deciding yet"
5556                    ),
5557                );
5558            }
5559            E2eStatus::Passed | E2eStatus::Deferred | E2eStatus::NotConfigured => {
5560                self.state.event(
5561                    "review",
5562                    format!("{why}; e2e is green — handing off with {open} finding(s) still open"),
5563                );
5564                self.record_contested_handoff();
5565                self.state.status = RunStatus::Gating;
5566            }
5567        }
5568        self.state.save()?;
5569        Ok(())
5570    }
5571
5572    // ----------------------------------------------------------------- gate
5573
5574    async fn gate(&mut self) -> Result<()> {
5575        // Judged by the review record itself, not by `status`: a solo
5576        // candidate's `judge`/`deliberate` skip rewrites `status` on every
5577        // reentry (see `judge`), and trusting it here is exactly how a run
5578        // that exhausted its review budget got gated and merged a second
5579        // time around. `review_conclusion` recomputes the review loop's own
5580        // verdict from the round records themselves — `Gating` for a clean
5581        // round or a hand-off (see `stop_reviewing`), anything else means the
5582        // loop is still going or genuinely blocked.
5583        // A base the winner could not be replayed onto is a decision, not a
5584        // round: there is no landing tree to gate. Read as its own record for
5585        // the same reason the review verdict is.
5586        if self.state.status == RunStatus::Failed
5587            || self
5588                .state
5589                .base_sync
5590                .as_ref()
5591                .is_some_and(|s| s.conflict.is_some())
5592            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
5593                != Some(RunStatus::Gating)
5594        {
5595            return Ok(());
5596        }
5597        if self.state.gate_ran {
5598            // `review_loop` derives its conclusion from the clean review
5599            // record on every reentry and therefore puts a completed run back
5600            // in `Gating`. A recorded gate is a stronger, terminal fact:
5601            // retain its original command output (or lack of any, for a repo
5602            // with no `verify.gate` commands — see `RunState::gate_ran`'s own
5603            // doc) and restore `Blocked` on a real failure rather than
5604            // pretending the command is still running or running it a second
5605            // time. `gate_ran == false` remains the only shape — unattempted,
5606            // or a resource-blocked retry — that may still need to execute a
5607            // command.
5608            if self.state.gate.iter().any(|outcome| !outcome.ok()) {
5609                self.state.status = RunStatus::Blocked;
5610                self.state.save()?;
5611            }
5612            return Ok(());
5613        }
5614        let Some(winner) = self.state.winner().cloned() else {
5615            return Ok(());
5616        };
5617        self.state.status = RunStatus::Gating;
5618        let mut outcomes = self.run_gate(&winner).await?;
5619        loop {
5620            // A resource-blocked outcome means the gate command never actually
5621            // ran - the shared build cache could not be acquired or confirmed
5622            // fresh in time - which is evidence about the machine, not about
5623            // the tree (see `CommandOutcome::resource_blocked`'s own doc).
5624            // Recording it as a red gate would mark a run `Blocked` on nothing
5625            // but contention magi has already logged; leaving `self.state.gate`
5626            // empty and `self.state.gate_ran` false instead keeps the shape
5627            // this function already treats as "still needs to run" (see the
5628            // early-return above), so the next call retries the command
5629            // rather than concluding anything.
5630            if verify_inconclusive(&outcomes) {
5631                self.state.save()?;
5632                return Ok(());
5633            }
5634            if outcomes.iter().all(CommandOutcome::ok) {
5635                break;
5636            }
5637            match self.gate_fix_round(&winner, &outcomes).await? {
5638                GateFix::Retry => outcomes = self.run_gate(&winner).await?,
5639                GateFix::Stop => break,
5640                GateFix::Defer => {
5641                    self.state.save()?;
5642                    return Ok(());
5643                }
5644            }
5645        }
5646        let passed = outcomes.iter().all(CommandOutcome::ok);
5647        self.state.gate = outcomes;
5648        self.state.gate_ran = true;
5649        if !passed {
5650            self.state.status = RunStatus::Blocked;
5651            let spent = self.state.gate_fixes.len();
5652            self.state.event(
5653                "gate",
5654                if spent == 0 {
5655                    "gate failed; not merging".to_owned()
5656                } else {
5657                    format!("gate failed after {spent} gate-fix round(s); not merging")
5658                },
5659            );
5660        }
5661        self.state.save()?;
5662        Ok(())
5663    }
5664
5665    /// Run `verify.pre_gate` in the winner's worktree, then fold whatever it
5666    /// changed into one commit. Reached only from [`Self::run_gate`], i.e.
5667    /// after review is clean and never on a candidate awaiting judging.
5668    ///
5669    /// Never fails the run: a non-zero exit or timeout is a warning and a
5670    /// recorded outcome, and the gate remains the single arbiter. Nothing
5671    /// configured means nothing happens - no event, no commit. `commit_all`
5672    /// commits any leftover change under the neutral identity and returns
5673    /// `false` when the tree is clean, so no empty commit is ever made.
5674    async fn run_pre_gate(&mut self, winner: &Candidate) {
5675        let commands = self.state.config.verify.pre_gate.clone();
5676        if commands.is_empty() {
5677            return;
5678        }
5679        let shell = self.state.config.shell();
5680        let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5681        let (outcomes, _) = run_commands(
5682            &mut self.state,
5683            "pre_gate",
5684            "pre_gate",
5685            0,
5686            &shell,
5687            &commands,
5688            &winner.worktree,
5689            timeout,
5690        )
5691        .await;
5692        for o in &outcomes {
5693            if !o.ok() {
5694                tracing::warn!(
5695                    "pre_gate `{}` failed ({:?}); the gate decides",
5696                    o.command,
5697                    o.code
5698                );
5699            }
5700            self.state.event(
5701                "pre_gate",
5702                format!(
5703                    "`{}` -> {}",
5704                    o.command,
5705                    if o.ok() {
5706                        "pass".to_owned()
5707                    } else {
5708                        format!(
5709                            "FAIL ({:?})\n{}",
5710                            o.code,
5711                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5712                        )
5713                    }
5714                ),
5715            );
5716        }
5717        self.state.pre_gate = outcomes;
5718        match git::commit_all(&winner.worktree, "magi: pre_gate (mechanical fixes)").await {
5719            Ok(true) => match git::rev_parse(&winner.worktree, "HEAD").await {
5720                Ok(head) => {
5721                    self.state
5722                        .event("pre_gate", format!("committed mechanical fixes ({head})"));
5723                    self.state.pre_gate_commit = Some(head);
5724                }
5725                Err(e) => tracing::warn!("pre_gate committed but HEAD unreadable: {e:#}"),
5726            },
5727            Ok(false) => {}
5728            Err(e) => tracing::warn!("pre_gate could not commit its changes: {e:#}"),
5729        }
5730        if let Err(e) = self.state.save() {
5731            tracing::warn!("could not persist the pre_gate record: {e:#}");
5732        }
5733    }
5734
5735    /// Run `verify.gate` once against the winner's current tree, logging one
5736    /// event per command. Empty when nothing is configured.
5737    async fn run_gate(&mut self, winner: &Candidate) -> Result<Vec<CommandOutcome>> {
5738        self.run_pre_gate(winner).await;
5739        let shell = self.state.config.shell();
5740        let gate_commands = self.state.config.verify.gate.clone();
5741        // Zero commands has nothing to run and nothing that could touch the
5742        // shared build cache, so it never needs a lease: `Config::cache_dir`
5743        // is derived from `verify.e2e` too, so a repo with no `verify.gate`
5744        // commands but a `CARGO_TARGET_DIR`-using `verify.e2e` would
5745        // otherwise queue behind an unrelated run's lease and come back
5746        // resource-blocked - `gate_ran` would stay false on nothing but
5747        // cache contention, for a step that had nothing to check in the
5748        // first place.
5749        let outcomes = if gate_commands.is_empty() {
5750            Vec::new()
5751        } else {
5752            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5753            let cache_dir = self.state.config.cache_dir();
5754            let head = git::rev_parse(&winner.worktree, "HEAD").await?;
5755            let (outcomes, _) = with_cache_lease(
5756                &mut self.state,
5757                cache_dir.as_deref(),
5758                "gate",
5759                "gate",
5760                &winner.worktree,
5761                &head,
5762                timeout,
5763                "final gate",
5764                |state, budget| {
5765                    let shell = shell.clone();
5766                    let gate_commands = gate_commands.clone();
5767                    let worktree = winner.worktree.clone();
5768                    async move {
5769                        let (outcomes, timed_out_pids) = run_commands(
5770                            state,
5771                            "gate",
5772                            "gate",
5773                            0,
5774                            &shell,
5775                            &gate_commands,
5776                            &worktree,
5777                            budget,
5778                        )
5779                        .await;
5780                        (outcomes, false, timed_out_pids)
5781                    }
5782                },
5783            )
5784            .await;
5785            outcomes
5786        };
5787        if outcomes.is_empty() {
5788            // Nothing configured to check — distinct from every other
5789            // silence in this run's event log, since an empty `gate` alone
5790            // no longer says whether the gate ran at all (see
5791            // `RunState::gate_ran`'s own doc).
5792            self.state.event(
5793                "gate",
5794                "no gate commands configured; nothing to check, passing",
5795            );
5796        }
5797        for o in &outcomes {
5798            self.state.event(
5799                "gate",
5800                format!(
5801                    "`{}` -> {}",
5802                    o.command,
5803                    if o.ok() {
5804                        "pass".to_owned()
5805                    } else {
5806                        format!(
5807                            "FAIL ({:?})\n{}",
5808                            o.code,
5809                            tail(&o.output_tail, EVENT_OUTPUT_TAIL)
5810                        )
5811                    }
5812                ),
5813            );
5814        }
5815        Ok(outcomes)
5816    }
5817
5818    /// One bounded fix round for a failing gate.
5819    ///
5820    /// The fixer is told the failure came from the gate itself, not from a
5821    /// reviewer, and is shown the failed commands, their exit codes and a tail
5822    /// of their output - whatever `[verify].gate` holds, nothing here knows
5823    /// what those commands run. Only a normal non-zero exit that printed
5824    /// something earns a round (see [`gate_fixable`]): a timeout, a missing
5825    /// command or a full disk says nothing about the code, and a fixer sent
5826    /// after it can only appease the machine. The round is judged by what git
5827    /// says moved, never by the fixer's own report, and `verify.e2e` runs
5828    /// again before the gate does, so a fix cannot trade a green gate for a
5829    /// red e2e unnoticed.
5830    async fn gate_fix_round(
5831        &mut self,
5832        winner: &Candidate,
5833        outcomes: &[CommandOutcome],
5834    ) -> Result<GateFix> {
5835        let cap = self.state.config.graph.gate_fix_rounds;
5836        let spent = self.state.gate_fixes.len();
5837        if spent >= cap {
5838            if cap > 0 {
5839                self.state.event(
5840                    "gate",
5841                    format!("{spent} gate-fix round(s) spent and the gate still fails"),
5842                );
5843            }
5844            return Ok(GateFix::Stop);
5845        }
5846        if !gate_fixable(outcomes) {
5847            self.state.event(
5848                "gate",
5849                "gate failure is not an ordinary non-zero exit with output (timeout, missing \
5850                 command or similar); not spending a fix round on it",
5851            );
5852            return Ok(GateFix::Stop);
5853        }
5854        let min_free = self.state.config.disk.min_free_bytes;
5855        if min_free > 0 {
5856            match crate::disk::free_bytes(&winner.worktree) {
5857                Ok(free) if crate::disk::enough_space(free, min_free) => {}
5858                Ok(free) => {
5859                    self.state.event(
5860                        "gate",
5861                        format!(
5862                            "only {free} bytes free ({min_free} required by `[disk] \
5863                             min_free_bytes`); not spending a fix round on a failure the disk \
5864                             may explain"
5865                        ),
5866                    );
5867                    return Ok(GateFix::Stop);
5868                }
5869                Err(e) => {
5870                    self.state.event(
5871                        "gate",
5872                        format!("free disk space could not be measured ({e:#}); no fix round"),
5873                    );
5874                    return Ok(GateFix::Stop);
5875                }
5876            }
5877        }
5878
5879        let attempt = spent + 1;
5880        let failed: Vec<CommandOutcome> = outcomes.iter().filter(|o| !o.ok()).cloned().collect();
5881        let base = self.landing_base();
5882        let fix_prompt = prompt::gate_fix(
5883            &self.state.instruction,
5884            &failed,
5885            attempt,
5886            cap,
5887            &self.state.config.graph.language,
5888        );
5889        let timeout = Duration::from_secs(self.state.config.graph.timeout_fix);
5890        let sessions = self.state.config.graph.sessions;
5891        let artifacts = agent::artifacts_dir(&self.state.dir());
5892        self.state.event(
5893            "gate",
5894            format!("gate failed; gate-fix round {attempt} of {cap}"),
5895        );
5896        let before = git::rev_parse(&winner.worktree, "HEAD").await?;
5897        let patch = git::diff(&winner.worktree, &base, "HEAD").await?;
5898        let (_, seat, out) = self
5899            .ask_fixer(winner, "gate-fix", None, |spec, seat| SeatJob {
5900                prompt: fix_prompt.clone(),
5901                spec,
5902                seat,
5903                cwd: winner.worktree.clone(),
5904                timeout,
5905                allow_write: true,
5906                sessions,
5907                artifacts: artifacts.clone(),
5908                stem: format!("gate-fix-{attempt}"),
5909                handover: None,
5910            })
5911            .await;
5912        let mut record = GateFixRecord {
5913            agent: seat.agent.clone(),
5914            failed,
5915            notes: String::new(),
5916            committed: false,
5917            error: None,
5918        };
5919        match out {
5920            AgentOutcome::Ok(o) => {
5921                // A missing report is not a failed fix: the round is judged
5922                // by the tree below, and the report only carries prose.
5923                if let Ok(report) = verdict::extract_json::<FixReport>(&o.text) {
5924                    record.notes = blind::sanitize_prose(&report.notes, &self.state.config.blind);
5925                }
5926            }
5927            AgentOutcome::Dropped(_) => {
5928                record.error = Some("the CLI dropped the stream".to_owned());
5929            }
5930            AgentOutcome::Quota(o) => {
5931                self.state.quota.push(QuotaLoss {
5932                    seat: seat.key.clone(),
5933                    node: "gate-fix".to_owned(),
5934                    at: Timestamp::now(),
5935                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
5936                });
5937                record.error = Some("rate limited (quota); fixer could not run".to_owned());
5938            }
5939            AgentOutcome::Failed(e) => record.error = Some(e),
5940        }
5941        self.state.seats.insert(seat.key.clone(), seat);
5942        if let Ok(r) = git::rescue_commit(
5943            &winner.worktree,
5944            &format!("magi: gate fix {attempt} (uncommitted work)"),
5945        )
5946        .await
5947        {
5948            self.state.note_withheld("gate-fix", &r.withheld);
5949        }
5950        let after = git::rev_parse(&winner.worktree, "HEAD").await?;
5951        record.committed = after != before;
5952        let changed = git::diff(&winner.worktree, &base, "HEAD").await? != patch;
5953        let note = record.error.clone();
5954        self.state.gate_fixes.push(record);
5955        self.state.save()?;
5956        if !changed {
5957            self.state.event(
5958                "gate",
5959                match note {
5960                    Some(why) => format!("gate-fix round {attempt}: fixer failed ({why})"),
5961                    None => format!("gate-fix round {attempt}: the tree did not change"),
5962                },
5963            );
5964            return Ok(GateFix::Stop);
5965        }
5966        self.state.event(
5967            "gate",
5968            format!("gate-fix round {attempt}: tree changed vs base; re-running verify.e2e"),
5969        );
5970
5971        let commands = self.state.config.verify.e2e.clone();
5972        if !commands.is_empty() {
5973            let shell = self.state.config.shell();
5974            let timeout = Duration::from_secs(self.state.config.graph.verify_timeout());
5975            let cache_dir = self.state.config.cache_dir();
5976            let context = format!("gate-fix round {attempt}");
5977            let (e2e, _) = with_cache_lease(
5978                &mut self.state,
5979                cache_dir.as_deref(),
5980                "e2e",
5981                "e2e",
5982                &winner.worktree,
5983                &after,
5984                timeout,
5985                &context,
5986                |state, budget| {
5987                    let shell = shell.clone();
5988                    let commands = commands.clone();
5989                    let context = context.clone();
5990                    let worktree = winner.worktree.clone();
5991                    async move {
5992                        run_e2e_with_retry(state, &shell, &commands, &worktree, budget, &context)
5993                            .await
5994                    }
5995                },
5996            )
5997            .await;
5998            if verify_inconclusive(&e2e) {
5999                return Ok(GateFix::Defer);
6000            }
6001            if e2e.iter().any(|o| !o.ok()) {
6002                self.state.event(
6003                    "gate",
6004                    format!("gate-fix round {attempt}: verify.e2e failed after the fix"),
6005                );
6006                return Ok(GateFix::Stop);
6007            }
6008        }
6009        Ok(GateFix::Retry)
6010    }
6011
6012    // ---------------------------------------------------------------- merge
6013
6014    /// One read-only rewrite by the summary's author; a text the gate still
6015    /// withholds goes to the owner (see `github_text`) and the fixed fallback
6016    /// applies on their word or on silence. `None` means the run is parked on
6017    /// that question and nothing may be pushed or opened yet.
6018    async fn guarded_pr_message(
6019        &mut self,
6020        winner: &Candidate,
6021        facts: Option<&BranchFacts>,
6022        posting: bool,
6023    ) -> Result<Option<PrMessage>> {
6024        let mut pr = pr_message_raw(&self.state, winner.label, facts);
6025        if !posting {
6026            let identity = crate::scrub::Identity::current();
6027            return Ok(Some(PrMessage {
6028                title: crate::scrub::scrub(&pr.title, &identity),
6029                body: crate::scrub::scrub(&pr.body, &identity),
6030            }));
6031        }
6032        // This very text was already rejected and asked about: no second
6033        // judge call, rewrite or question, just carry the record on.
6034        let raw_fp = crate::github_text::fingerprint(&pr.title, &pr.body);
6035        if let Some(known) = self
6036            .state
6037            .github_text
6038            .clone()
6039            .filter(|g| g.fingerprint == raw_fp)
6040        {
6041            return self.settle_withheld(winner, &pr, known).await;
6042        }
6043        let mut decision = self.judge_pr_language(&winner.worktree, &pr).await;
6044        let mut violations = crate::github_text::check_with(&pr.title, &pr.body, decision);
6045        let identity = crate::scrub::Identity::current();
6046        if (crate::scrub::scrub(&pr.title, &identity) != pr.title
6047            || crate::scrub::scrub(&pr.body, &identity) != pr.body)
6048            && !violations.contains(&crate::github_text::Violation::SensitiveData)
6049        {
6050            violations.push(crate::github_text::Violation::SensitiveData);
6051        }
6052        // A rewrite cannot fix sensitive data (e.g. a quoted original task), so
6053        // only a language violation starts one; sensitive data alone goes
6054        // straight to the owner, with the redacted text as the fallback.
6055        let has_language = violations
6056            .iter()
6057            .any(|v| *v != crate::github_text::Violation::SensitiveData);
6058        if self.state.config.graph.github_text_guard && !violations.is_empty() {
6059            self.state.event(
6060                "github-text",
6061                if has_language {
6062                    format!("description rejected: {violations:?}; requesting one rewrite")
6063                } else {
6064                    format!("description rejected: {violations:?}; asking the owner")
6065                },
6066            );
6067            let mut rewritten = false;
6068            if has_language
6069                && let Some(spec) = self
6070                    .state
6071                    .config
6072                    .agents
6073                    .iter()
6074                    .find(|a| a.id == winner.agent)
6075                    .cloned()
6076            {
6077                let key = format!("impl-{}", winner.label);
6078                let seat = self.seat(&key, &spec.id);
6079                let prompt = format!(
6080                    "Rewrite only the following pull request description. The posting gate reported {violations:?}. Write English prose and remove all machine or operator identifying data and secrets. Do not edit files or run commands. Return TITLE: followed by the title, then the complete Markdown body. Preserve the magi run marker.\n\nTITLE: {}\n{}",
6081                    pr.title, pr.body
6082                );
6083                let job = SeatJob {
6084                    spec,
6085                    seat,
6086                    cwd: winner.worktree.clone(),
6087                    prompt,
6088                    timeout: retry_budget(
6089                        Duration::from_secs(self.state.config.graph.timeout_implement),
6090                        true,
6091                    ),
6092                    allow_write: false,
6093                    sessions: self.state.config.graph.sessions,
6094                    artifacts: agent::artifacts_dir(&self.state.dir()),
6095                    stem: "github-text-rewrite".to_owned(),
6096                    handover: None,
6097                };
6098                let prompts = self.state.config.prompts.clone();
6099                let cache = self.state.config.cache_dir();
6100                let run = self.state.id.clone();
6101                let ctx = WaveCtx {
6102                    run: &run,
6103                    node: "github-text",
6104                    prompts: &prompts,
6105                    cache: cache.as_deref(),
6106                    round: None,
6107                    carry_seats: false,
6108                };
6109                let (seat, outcome) =
6110                    run_one(job, Arc::clone(&self.sem), &ctx, &mut self.state, 1).await;
6111                self.state.seats.insert(seat.key.clone(), seat);
6112                if let AgentOutcome::Ok(output) = outcome
6113                    && let Some(title) = summary_title(&output.text)
6114                {
6115                    let mut body = summary_without_title(&output.text);
6116                    let rewrite = PrMessage {
6117                        title: title.clone(),
6118                        body: body.clone(),
6119                    };
6120                    let redecision = self.judge_pr_language(&winner.worktree, &rewrite).await;
6121                    if !body.trim().is_empty()
6122                        && crate::github_text::check_with(&title, &body, redecision).is_empty()
6123                        && crate::github_text::shareable(&title)
6124                        && crate::github_text::shareable(&body)
6125                    {
6126                        decision = redecision;
6127                        let marker = format!("magi:run/{}", self.state.id);
6128                        if !body.contains(&marker) {
6129                            body.push_str(&format!("\n\n{marker}"));
6130                        }
6131                        pr = PrMessage { title, body };
6132                        rewritten = true;
6133                        self.state
6134                            .event("github-text", "description rewrite passed");
6135                    }
6136                }
6137            }
6138            if !rewritten {
6139                self.state.event(
6140                    "github-text",
6141                    "description rewrite unavailable or rejected; asking the owner",
6142                );
6143                if let Some(w) =
6144                    crate::github_text::Withheld::from_check(&violations, &pr.title, &pr.body)
6145                {
6146                    // A different text than the one asked about before: that
6147                    // question no longer describes anything.
6148                    self.retire_github_text_question("the withheld text changed");
6149                    let record = crate::run::GithubTextAsk {
6150                        fingerprint: raw_fp,
6151                        title: w.title,
6152                        body: w.body,
6153                        categories: w.categories.iter().map(|c| (*c).to_owned()).collect(),
6154                        question: None,
6155                        asks: 0,
6156                        resolved: false,
6157                        chosen_title: None,
6158                    };
6159                    return self.settle_withheld(winner, &pr, record).await;
6160                }
6161            }
6162        }
6163        let (title, body) =
6164            crate::github_text::prepare_with(&mut self.state, &pr.title, &pr.body, decision);
6165        Ok(Some(PrMessage { title, body }))
6166    }
6167
6168    /// Abandon the standing posting-gate question, if any.
6169    fn retire_github_text_question(&mut self, why: &str) {
6170        let Some(id) = self
6171            .state
6172            .github_text
6173            .as_ref()
6174            .and_then(|g| g.question.clone())
6175        else {
6176            return;
6177        };
6178        let _ = ask::Questions::open().update(&id, |q| {
6179            q.abandon(why);
6180            Ok(())
6181        });
6182    }
6183
6184    /// Carry a withheld text to its end: file the question, park while it is
6185    /// open, and on an answer, silence or timeout pick the text to post. The
6186    /// decision is saved before it is used, so it applies once.
6187    async fn settle_withheld(
6188        &mut self,
6189        winner: &Candidate,
6190        pr: &PrMessage,
6191        mut g: crate::run::GithubTextAsk,
6192    ) -> Result<Option<PrMessage>> {
6193        use crate::github_text as gt;
6194        let store = ask::Questions::open();
6195        let timeout = self.state.config.graph.answer_timeout;
6196        if !g.resolved {
6197            let mut standing = g
6198                .question
6199                .as_ref()
6200                .and_then(|id| store.list().into_iter().find(|q| &q.id == id));
6201            if let Some(q) = standing.as_ref()
6202                && q.status.open()
6203                && gt::expired(q, timeout)
6204            {
6205                let why = format!("no answer within {}s of asking", timeout.max(1));
6206                standing = store
6207                    .update(&q.id, |q| {
6208                        q.abandon(&why);
6209                        Ok(())
6210                    })
6211                    .ok()
6212                    .map(|(q, ())| q);
6213            }
6214            let mut ask_again: Option<(Vec<String>, Option<String>)> = None;
6215            match standing {
6216                // Recorded but never written (a stop between the two saves),
6217                // or removed: ask again without spending the retry.
6218                None if g.asks <= 1 => {
6219                    g.asks = 0;
6220                    ask_again = Some((g.categories.clone(), None));
6221                }
6222                None => {
6223                    g.resolved = true;
6224                    g.chosen_title = None;
6225                }
6226                Some(q) if q.status.open() => {
6227                    self.park_on_github_text(&g)?;
6228                    return Ok(None);
6229                }
6230                Some(q) => match gt::read_reply(&q) {
6231                    gt::Reply::Fallback => {
6232                        g.resolved = true;
6233                        g.chosen_title = None;
6234                    }
6235                    gt::Reply::Title(text) => {
6236                        let first = text
6237                            .lines()
6238                            .map(str::trim)
6239                            .find(|l| !l.is_empty())
6240                            .unwrap_or("");
6241                        let decision = if first.is_empty() {
6242                            None
6243                        } else {
6244                            let candidate = PrMessage {
6245                                title: first.to_owned(),
6246                                body: String::new(),
6247                            };
6248                            self.judge_pr_language(&winner.worktree, &candidate).await
6249                        };
6250                        match gt::vet_title(&text, decision) {
6251                            Ok(title) => {
6252                                g.resolved = true;
6253                                g.chosen_title = Some(title);
6254                            }
6255                            Err(cats) if g.asks < 2 => {
6256                                ask_again = Some((
6257                                    cats.into_iter().map(str::to_owned).collect(),
6258                                    Some(first.to_owned()),
6259                                ))
6260                            }
6261                            Err(_) => {
6262                                g.resolved = true;
6263                                g.chosen_title = None;
6264                            }
6265                        }
6266                    }
6267                },
6268            }
6269            if let Some((categories, replacement)) = ask_again {
6270                let retry = replacement.is_some();
6271                // After a retry the question is about the owner's rejected
6272                // replacement title, not the original message.
6273                let (shown_title, shown_body) = match replacement.as_deref() {
6274                    Some(t) => (t, ""),
6275                    None => (pr.title.as_str(), pr.body.as_str()),
6276                };
6277                let w = gt::Withheld {
6278                    title: g.title || retry,
6279                    body: g.body && !retry,
6280                    categories,
6281                };
6282                // Named by the shown text's fingerprint alone, so a resume rewrites
6283                // the same file and the detail stays identical.
6284                let artifact = match crate::run::write_artifact(
6285                    &self.state,
6286                    &gt::artifact_name(&gt::fingerprint(shown_title, shown_body)),
6287                    &gt::artifact_text(shown_title, shown_body),
6288                ) {
6289                    Ok(p) => Some(p),
6290                    Err(e) => {
6291                        tracing::warn!("could not write the withheld-text artifact: {e:#}");
6292                        None
6293                    }
6294                };
6295                let mut q = ask::Question::new(
6296                    self.state.id.clone(),
6297                    gt::ASK_NODE.to_owned(),
6298                    gt::ASK_SEAT.to_owned(),
6299                    gt::question_summary(&self.state.config.graph.language, &w),
6300                    gt::question_detail(&w, shown_title, shown_body, retry, artifact.as_deref()),
6301                    gt::question_choices(&w),
6302                );
6303                q.answer_timeout = timeout;
6304                // Recorded before it is written: a stop in between leaves a
6305                // record naming a question the resume then files again.
6306                g.question = Some(q.id.clone());
6307                g.asks += 1;
6308                self.state.github_text = Some(g.clone());
6309                self.state.event(
6310                    "github-text",
6311                    format!("asking the owner about the withheld text ({})", q.short()),
6312                );
6313                self.state.save()?;
6314                store
6315                    .put(&mut q)
6316                    .context("file the posting-gate question")?;
6317                if let Err(e) = ask::notify(&self.state.config.notify, &q).await {
6318                    tracing::warn!(
6319                        "could not notify about posting-gate question {}: {e:#}",
6320                        q.short()
6321                    );
6322                }
6323                self.park_on_github_text(&g)?;
6324                return Ok(None);
6325            }
6326        }
6327        let resolved = g.chosen_title.clone();
6328        self.state.event(
6329            "github-text",
6330            if resolved.is_some() {
6331                "posting the owner's replacement title"
6332            } else {
6333                "posting the neutral text for the withheld fields"
6334            },
6335        );
6336        self.state.github_text = Some(g.clone());
6337        self.state.save()?;
6338        // A field withheld only for sensitive data keeps its text; `prepare_with`
6339        // below redacts it in place.
6340        let neutral = |cat: &str| g.categories.iter().any(|c| c == cat);
6341        let title = match (g.title, resolved) {
6342            (true, Some(t)) => t,
6343            (true, None) if neutral("title-language") => gt::NEUTRAL_TITLE.to_owned(),
6344            _ => pr.title.clone(),
6345        };
6346        let body = if g.body && neutral("body-language") {
6347            format!("{}\n\nmagi:run/{}", gt::NEUTRAL_BODY, self.state.id)
6348        } else {
6349            pr.body.clone()
6350        };
6351        // Every withheld field is now neutral or vetted English.
6352        let english = gt::LanguageDecision {
6353            title_english: true,
6354            body_english: true,
6355            body_complete: true,
6356        };
6357        let (title, body) = gt::prepare_with(&mut self.state, &title, &body, Some(english));
6358        Ok(Some(PrMessage { title, body }))
6359    }
6360
6361    fn park_on_github_text(&mut self, g: &crate::run::GithubTextAsk) -> Result<()> {
6362        self.state.github_text = Some(g.clone());
6363        self.state.parked = true;
6364        self.state.event(
6365            "github-text",
6366            "parked awaiting the owner's word on the withheld pull request text - resumes once answered",
6367        );
6368        self.state.save()
6369    }
6370
6371    /// Ask `[roles] language_judge` about this text and record which source
6372    /// decided; the text itself is never recorded.
6373    async fn judge_pr_language(
6374        &mut self,
6375        cwd: &Path,
6376        pr: &PrMessage,
6377    ) -> Option<crate::github_text::LanguageDecision> {
6378        self.state.config.roles.language_judge.as_ref()?;
6379        let decision =
6380            crate::github_text::judge_language(&self.state.config, cwd, &pr.title, &pr.body).await;
6381        self.state.event(
6382            "github-text",
6383            if decision.is_some() {
6384                "language decided by the language judge"
6385            } else {
6386                "language judge unavailable; using built-in heuristics"
6387            },
6388        );
6389        decision
6390    }
6391
6392    async fn merge(&mut self) -> Result<()> {
6393        // Same reasoning as `gate`: ask the review and gate records directly
6394        // rather than `status`, which a solo-candidate `judge`/`deliberate`
6395        // skip can rewrite on reentry to something that no longer says
6396        // `Blocked`. `review_conclusion` is the same derivation `gate` uses,
6397        // so a hand-off (open findings, green verification) reaches merge
6398        // exactly like a genuinely clean round does.
6399        //
6400        // A run resumed mid-`land` never reaches here at all: `execute`
6401        // recognises `RunStatus::Landing` before it even calls `prep`, and
6402        // routes straight to `run_land` instead. That has to happen a level
6403        // up from this function, not with a check in here, because
6404        // `review_loop`'s own status recomputation (see its doc) runs
6405        // *before* `merge` on every reentry and would otherwise overwrite
6406        // the `Landing` marker with `Gating` before this node ever saw it.
6407        if self
6408            .state
6409            .base_sync
6410            .as_ref()
6411            .is_some_and(|s| s.conflict.is_some())
6412            || review_conclusion(&self.state.reviews, self.state.config.graph.review_rounds)
6413                != Some(RunStatus::Gating)
6414            // `gate_ran == false` is not "passed" - `gate` leaves it false
6415            // both before it has ever run and when its last attempt was
6416            // resource-blocked (see `Runner::gate`'s own doc), and neither is
6417            // permission to merge on nothing but the review record. Only a
6418            // gate that actually ran - zero commands configured and
6419            // vacuously passed, or one or more that all exited 0 - may
6420            // proceed; `RunState::gate_status` is the single place that
6421            // reading is computed.
6422            || !self.state.gate_status().ok()
6423        {
6424            return Ok(());
6425        }
6426        // This node's own record, not `status`: `status == Ready` is not
6427        // unique to the harmless `MergeMode::None` path this line was
6428        // written for. `land` (below) sets it too, when a `MergeMode::Pr`
6429        // run's PR was closed without merging — and on that run `mode` is
6430        // still `Pr`, so a reentry that fell through here would push and
6431        // open a second pull request. `self.state.merge` is set exactly once
6432        // this node (or `land`) has already produced a verdict, under every
6433        // mode, which is what "already done" actually means here.
6434        if self.state.merge.is_some() {
6435            return Ok(());
6436        }
6437        let Some(winner) = self.state.winner().cloned() else {
6438            return Ok(());
6439        };
6440        let repo = self.state.repo.clone();
6441        let base = self.state.base_branch.clone();
6442        let mode = self.state.config.merge.mode;
6443        let style = self.state.config.merge.style;
6444        let facts = if is_review_run(&self.state) {
6445            refresh_reviewed_commits(&mut self.state, &winner.branch).await;
6446            let start = review_base(
6447                &repo,
6448                &self.state.config.merge.remote,
6449                &base,
6450                &self.state.base_commit,
6451                &winner.branch,
6452            )
6453            .await;
6454            branch_facts(&repo, &start, &winner.branch).await
6455        } else {
6456            None
6457        };
6458        // `None` when the base could not be freshly read: then an adopted
6459        // pull request's title is left alone.
6460        let leaked = if is_review_run(&self.state) {
6461            leaked_subjects(&self.state, &winner.branch).await
6462        } else {
6463            Some(Vec::new())
6464        };
6465        // `None`: parked on the owner's word about a withheld title or body.
6466        // Nothing has been pushed or opened, and `merge` stays unrecorded so
6467        // the resume arrives here again.
6468        let Some(pr) = self
6469            .guarded_pr_message(&winner, facts.as_ref(), mode == MergeMode::Pr)
6470            .await?
6471        else {
6472            return Ok(());
6473        };
6474        let message = pr.commit_message();
6475
6476        let outcome = match mode {
6477            MergeMode::None => MergeOutcome {
6478                mode,
6479                ok: true,
6480                detail: manual_merge_command(style, &repo, &winner.branch, &message),
6481                empty: false,
6482            },
6483            MergeMode::Pr | MergeMode::Local
6484                if merge_is_empty(&repo, &self.state, &winner.branch, mode).await =>
6485            {
6486                MergeOutcome {
6487                    mode,
6488                    ok: false,
6489                    detail: empty_candidate_detail(&self.state, &base),
6490                    empty: true,
6491                }
6492            }
6493            MergeMode::Local => {
6494                let on = git::current_branch(&repo).await?;
6495                if on.as_deref() != Some(base.as_str()) {
6496                    MergeOutcome {
6497                        mode,
6498                        ok: false,
6499                        detail: format!(
6500                            "{} has {} checked out, not the base branch {base}",
6501                            repo.display(),
6502                            on.unwrap_or_else(|| "a detached HEAD".to_owned())
6503                        ),
6504                        empty: false,
6505                    }
6506                } else if !git::is_clean(&repo).await? {
6507                    MergeOutcome {
6508                        mode,
6509                        ok: false,
6510                        detail: format!("{} is dirty; refusing to merge", repo.display()),
6511                        empty: false,
6512                    }
6513                } else {
6514                    let out = match style {
6515                        MergeStyle::Merge => {
6516                            git::merge_no_ff(&repo, &winner.branch, &message).await?
6517                        }
6518                        MergeStyle::Squash => {
6519                            git::merge_squash(&repo, &winner.branch, &message).await?
6520                        }
6521                        MergeStyle::Rebase => git::merge_ff_only(&repo, &winner.branch).await?,
6522                    };
6523                    MergeOutcome {
6524                        mode,
6525                        ok: out.ok(),
6526                        detail: if out.ok() { out.stdout } else { out.stderr },
6527                        empty: false,
6528                    }
6529                }
6530            }
6531            MergeMode::Pr => {
6532                let remote = self.state.config.merge.remote.clone();
6533                let pushed = git::push(&winner.worktree, &remote, &winner.branch).await?;
6534                if !pushed.ok() {
6535                    MergeOutcome {
6536                        mode,
6537                        ok: false,
6538                        detail: pushed.stderr,
6539                        empty: false,
6540                    }
6541                } else {
6542                    // A retry or resume of a run whose branch already has an
6543                    // open pull request adopts it rather than failing on a
6544                    // duplicate. Only this winner branch into this base:
6545                    // `branch_for` derives the name from the run id, so a
6546                    // different run's pull request never matches.
6547                    let found = land::find_open_pr(&winner.worktree, &winner.branch, &base).await;
6548                    let out = match pr_merge_plan(found) {
6549                        PrPlan::Create => {
6550                            gh_pr_create(
6551                                &winner.worktree,
6552                                &base,
6553                                &winner.branch,
6554                                &pr.title,
6555                                &pr.body,
6556                            )
6557                            .await
6558                        }
6559                        PrPlan::Adopt { url, title } => {
6560                            self.state
6561                                .event("merge", format!("Pr: adopted open pull request {url}"));
6562                            if title != pr.title
6563                                && (!is_review_run(&self.state)
6564                                    || leaked
6565                                        .as_deref()
6566                                        .is_some_and(|l| should_retitle(&title, &pr.title, l)))
6567                                && let Err(e) = land::set_pr_title(
6568                                    &mut self.state,
6569                                    &winner.worktree,
6570                                    &url,
6571                                    &pr.title,
6572                                )
6573                                .await
6574                            {
6575                                tracing::warn!("could not refresh title of {url}: {e:#}");
6576                                self.state
6577                                    .event("merge", format!("Pr: title refresh failed: {e:#}"));
6578                            }
6579                            Ok(url)
6580                        }
6581                        PrPlan::Stop(why) => Err(anyhow::anyhow!(why)),
6582                    };
6583                    match out {
6584                        Ok(url) => MergeOutcome {
6585                            mode,
6586                            ok: true,
6587                            detail: url,
6588                            empty: false,
6589                        },
6590                        Err(e) => MergeOutcome {
6591                            mode,
6592                            ok: false,
6593                            detail: e.to_string(),
6594                            empty: false,
6595                        },
6596                    }
6597                }
6598            }
6599        };
6600
6601        self.state.status = match (mode, outcome.ok) {
6602            (MergeMode::None, _) => RunStatus::Ready,
6603            (_, true) => RunStatus::Merged,
6604            (_, false) => RunStatus::Blocked,
6605        };
6606        self.state.event(
6607            "merge",
6608            format!(
6609                "{:?}: {}",
6610                mode,
6611                outcome.detail.lines().next().unwrap_or("")
6612            ),
6613        );
6614        self.state.merge = Some(outcome);
6615        self.state.save()?;
6616
6617        // The PR is open and the run would historically stop here, leaving the
6618        // operator to watch checks, feed review comments back to a fixer, and
6619        // merge. That was done by hand six times in one session before this
6620        // existed. Opt-in, because merging is the one irreversible thing magi
6621        // can do to a repository.
6622        if self.state.config.graph.land
6623            && mode == MergeMode::Pr
6624            && self.state.status == RunStatus::Merged
6625        {
6626            self.run_land().await?;
6627        }
6628        // `run_land` may have left `status` at `Landing` - still waiting on
6629        // CI or the owner's approval, not actually settled - so this has to
6630        // read whatever `status` ended up as here, not the `Merged` this
6631        // function set a few lines up.
6632        self.settle_questions();
6633        Ok(())
6634    }
6635
6636    /// Enter `land`.
6637    ///
6638    /// Shared between a fresh run's first pass through [`Runner::merge`] and
6639    /// a resumed run's re-entry. `land::land` itself is what serialises the
6640    /// two git-mutating moments inside the loop — the rebase push and
6641    /// `gh pr merge` — per repository (see its own doc); nothing here needs
6642    /// to hold a lock across the whole call, and doing so would serialise
6643    /// this run's CI wait against a *different* run's land-approval resume
6644    /// in the same repository, which is exactly the "must not wait on
6645    /// another task" property the daemon's slot-freeing exists to give.
6646    async fn run_land(&mut self) -> Result<()> {
6647        let url = self
6648            .state
6649            .merge
6650            .as_ref()
6651            .map(|m| m.detail.clone())
6652            .unwrap_or_default();
6653        let url = url.lines().next().unwrap_or("").trim().to_owned();
6654        if !url.starts_with("http") {
6655            return Ok(());
6656        }
6657        // A land failure is not a lost run: the work is on a branch and the
6658        // pull request is open, which is exactly where a human takes over.
6659        match land::land(&mut self.state, &url).await {
6660            Ok(pr) if self.state.parked => {
6661                // `land` already saved the parked marker; nothing here
6662                // overrides `status` back to a terminal value while an
6663                // approval is still outstanding.
6664                let _ = pr;
6665            }
6666            Ok(pr) => {
6667                self.state.status = match pr.state {
6668                    land::PrLifecycle::Merged => RunStatus::Merged,
6669                    _ => RunStatus::Blocked,
6670                };
6671                // Downstream of a confirmed merge only - see
6672                // `bump::should_release_bump`'s own doc for why this one
6673                // check covers all three of `land`'s success paths.
6674                // Best-effort: the run already landed, so a failure here
6675                // (the decision call, `gh`, `cargo`) is recorded and never
6676                // turns a landed run into a failed one.
6677                if bump::should_release_bump(self.state.status)
6678                    && let Err(e) = bump::after_merge(&mut self.state, &pr.url).await
6679                {
6680                    // The event is the run's own record. Not-eligible cases
6681                    // (disabled, no `Cargo.toml`, ...) return `Ok`, so an
6682                    // `Err` is a bump that was tried and failed:
6683                    // `after_merge` itself raises the operator notice for
6684                    // that, whether or not a release PR exists yet.
6685                    self.state
6686                        .event("bump", format!("release bump skipped: {e:#}"));
6687                }
6688                // Independent of the bump, and best-effort in the same way:
6689                // findings the merge left open become follow-up tasks.
6690                if self.state.status == RunStatus::Merged {
6691                    crate::followup::after_merge(&mut self.state, &pr.url).await;
6692                }
6693                self.state.save()?;
6694            }
6695            Err(e) => {
6696                self.state.status = RunStatus::Blocked;
6697                self.state.event("land", format!("gave up: {e}"));
6698                self.state.save()?;
6699            }
6700        }
6701        Ok(())
6702    }
6703
6704    // -------------------------------------------------------------- helpers
6705
6706    /// Fetch or create a seat, keeping its conversation across nodes.
6707    fn seat(&mut self, key: &str, agent: &str) -> SeatState {
6708        if let Some(existing) = self.state.seats.get(key)
6709            && existing.agent == agent
6710        {
6711            return existing.clone();
6712        }
6713        // A seat that changes agent mints its session id from the agent too,
6714        // like a handover: the old agent's uuid is already taken by the CLI.
6715        let fresh = if self.state.seats.contains_key(key) {
6716            handover_seat(key, agent, self.state.next_seat_seed())
6717        } else {
6718            SeatState::new(key, agent, self.state.seed)
6719        };
6720        self.state.seats.insert(key.to_owned(), fresh.clone());
6721        fresh
6722    }
6723
6724    /// The agent now holding seat `key`: `spec`, unless a handover moved the
6725    /// seat to another roster agent, in which case that agent. Nodes that
6726    /// continue a seat's conversation (deliberation, the votes, a reviewer's
6727    /// reconsideration) must keep talking to whoever answered it, not slip
6728    /// back to the agent that failed it.
6729    fn occupant(&self, key: &str, spec: AgentSpec) -> AgentSpec {
6730        match self.state.seats.get(key) {
6731            Some(s) if s.agent != spec.id => {
6732                self.state.config.agent(&s.agent).cloned().unwrap_or(spec)
6733            }
6734            _ => spec,
6735        }
6736    }
6737
6738    /// A candidate rendered for judging, with the leak policy applied.
6739    fn view(&self, c: &Candidate) -> CandidateView {
6740        let raw = crate::run::read_artifact(&self.state, &format!("cand-{}.patch", c.label))
6741            .unwrap_or_default();
6742        let (patch, _) = blind::sanitize_patch(
6743            &format!("candidate {} patch", c.label),
6744            &raw,
6745            &self.state.config.blind,
6746        );
6747        CandidateView {
6748            label: c.label,
6749            branch: c.branch.clone(),
6750            summary: c.summary.clone(),
6751            stat: c.stat.clone(),
6752            patch,
6753        }
6754    }
6755
6756    /// The full candidate set as prompt text, for seats with no live session.
6757    fn candidate_block(&self, candidates: &[Candidate], base_short: &str) -> String {
6758        let views: Vec<CandidateView> = candidates.iter().map(|c| self.view(c)).collect();
6759        prompt::judge(
6760            "(see above)",
6761            &views,
6762            self.roles.judges.len(),
6763            base_short,
6764            "en",
6765        )
6766    }
6767
6768    /// The final-vote prompt with everything a seat that has no session of its
6769    /// own needs: the candidates, the seat's own ranking and reasons, and the
6770    /// anonymised deliberation it took part in (only when there was one, so a
6771    /// handed-over seat never sees more than the seat it replaces did). The
6772    /// `Final vote` heading stays first.
6773    fn vote_prompt_full(
6774        &self,
6775        j: usize,
6776        labels: &[char],
6777        language: &str,
6778        candidates: &[Candidate],
6779        base_short: &str,
6780    ) -> String {
6781        let mut text = format!(
6782            "{}\n\n# The task the candidates were given\n\n{}\n\n# Candidates\n\n{}",
6783            prompt::final_vote(labels, language),
6784            self.state.instruction,
6785            self.candidate_block(candidates, base_short)
6786        );
6787        if let Some(own) = self
6788            .state
6789            .judgements
6790            .get(j)
6791            .filter(|r| !r.ranking.is_empty())
6792        {
6793            let reasons = own
6794                .reasons
6795                .iter()
6796                .map(|(k, v)| format!("- {k}: {v}"))
6797                .collect::<Vec<_>>()
6798                .join("\n");
6799            text.push_str(&format!(
6800                "\n\n# Your own earlier ranking\n\nYou ranked {}{}{reasons}\n",
6801                own.ranking.iter().collect::<String>(),
6802                if reasons.is_empty() {
6803                    ""
6804                } else {
6805                    ", because:\n"
6806                }
6807            ));
6808        }
6809        if !self.state.deliberation.is_empty() {
6810            text.push_str("\n# What was argued before this vote\n");
6811            for t in self.transcript(&[], j) {
6812                text.push_str(&format!(
6813                    "\n## {}{}\n\n{}\n",
6814                    t.who,
6815                    if t.is_self { " (you)" } else { "" },
6816                    t.body.trim()
6817                ));
6818            }
6819        }
6820        text
6821    }
6822
6823    /// Anonymised transcript for judge `self_idx`.
6824    ///
6825    /// The initial rankings are always the opening statements. Seeding them
6826    /// only when no turn had been taken yet meant every judge after the first
6827    /// argued against a single voice instead of against the actual split — the
6828    /// disagreement is the information, so it is always on the table.
6829    fn transcript(&self, current: &[DeliberationTurn], self_idx: usize) -> Vec<Turn> {
6830        let mut turns = Vec::new();
6831        for j in &self.state.judgements {
6832            if j.ranking.is_empty() {
6833                continue;
6834            }
6835            let reasons = j
6836                .reasons
6837                .iter()
6838                .map(|(k, v)| format!("- {k}: {v}"))
6839                .collect::<Vec<_>>()
6840                .join("\n");
6841            turns.push(Turn {
6842                who: format!("Judge {} (opening ranking)", j.judge),
6843                is_self: j.judge == self_idx + 1,
6844                body: format!(
6845                    "Ranked {}{}{reasons}",
6846                    j.ranking.iter().collect::<String>(),
6847                    if reasons.is_empty() {
6848                        ""
6849                    } else {
6850                        ", because:\n"
6851                    }
6852                ),
6853            });
6854        }
6855        for t in self
6856            .state
6857            .deliberation
6858            .iter()
6859            .flat_map(|r| r.turns.iter())
6860            .chain(current)
6861        {
6862            turns.push(Turn {
6863                who: format!("Judge {}", t.judge),
6864                is_self: t.judge == self_idx + 1,
6865                body: t.body.clone(),
6866            });
6867        }
6868        turns
6869    }
6870}
6871
6872/// Does this seat still hold the context a follow-up prompt would rely on?
6873fn has_context(spec: &AgentSpec, seat: &SeatState, sessions: bool) -> bool {
6874    agent::has_session(spec.kind, seat, sessions)
6875}
6876
6877/// The next entry in `roster` after `start`, never wrapping back to the
6878/// front, whose id is not in `tried` yet.
6879///
6880/// Starts one past `start` rather than at the front of `roster`: `start` is
6881/// the seat's own original position, and a seat whose candidate slot already
6882/// sits on the roster's second entry must fall through to the third next, not
6883/// restart at the first — which is very likely a different candidate's own
6884/// agent already. Never wraps back past `start`, for the same reason: an
6885/// entry earlier in the roster than the seat's own position is almost
6886/// certainly some *other* candidate slot's own agent, and once the tail of
6887/// the roster is exhausted there are no more untried agents for *this* seat
6888/// to fall through to — the caller's fallback chain ends there, exactly as
6889/// "no further untried agents remain in the list for that seat" asks for.
6890///
6891/// Matched by [`AgentSpec::id`], never the whole spec: a roster that names
6892/// the same id twice (an operator's `roles.implementers` typo, or a
6893/// `[[agents]]` list reused across roles) must not let
6894/// [`Runner::resume_seat_handovers`] retry that id forever — one forward pass
6895/// over `roster` either finds an untried id or runs out, so this always
6896/// terminates regardless of duplicates.
6897fn next_untried_in_roster<'a>(
6898    roster: &'a [AgentSpec],
6899    start: usize,
6900    tried: &BTreeSet<String>,
6901) -> Option<&'a AgentSpec> {
6902    roster
6903        .get(start + 1..)?
6904        .iter()
6905        .find(|s| !tried.contains(&s.id))
6906}
6907
6908/// The successor for a seat, shared by all four seat kinds (implement, judge,
6909/// review, advise). `others` holds the ids that *currently* occupy the other
6910/// seats of the same wave (after any earlier handover, as [`Runner::occupant`]
6911/// sees them), so roster entries beyond the seat count act as spares: a failed
6912/// seat goes to an agent no other seat holds whenever the roster permits.
6913///
6914/// `carried` is `Some` only for the review loop's carried failure history.
6915/// Order: (1) forward from `start`, never wrapping, untried, not a carried
6916/// failure, not another seat's occupant; (2) with `carried`, a rescue over the
6917/// whole roster: untried and not another seat's occupant; (3) the plain walk
6918/// ([`next_untried_in_roster`] / [`next_for_seat`]) that ignores `others`.
6919/// Step 3 is deliberate: a duplicate agent on two seats is a worse panel but
6920/// a better outcome than an empty seat, and it keeps the answer to "is there
6921/// a successor at all" exactly what it was before occupants were considered,
6922/// so no handover rule (`should_hand_over`, nudges, the tried-once bound)
6923/// moves. The rescue excludes occupants too, on the same reasoning: retrying a
6924/// carried failure is a cheaper bet than doubling an agent on the panel, and
6925/// if it fails again `tried` bounds it and step 3 takes over. Seats failing in
6926/// the same round are handled one at a time, so a seat later in the batch
6927/// sees an earlier one's new occupant but not yet its own freed agent.
6928fn pick_successor<'a>(
6929    roster: &'a [AgentSpec],
6930    start: usize,
6931    tried: &BTreeSet<String>,
6932    carried: Option<&BTreeSet<String>>,
6933    others: &BTreeSet<String>,
6934) -> Option<&'a AgentSpec> {
6935    let free = |s: &&AgentSpec| !tried.contains(&s.id) && !others.contains(&s.id);
6936    roster
6937        .get(start + 1..)
6938        .and_then(|tail| {
6939            tail.iter()
6940                .filter(free)
6941                .find(|s| carried.is_none_or(|c| !c.contains(&s.id)))
6942        })
6943        .or_else(|| {
6944            carried
6945                .is_some()
6946                .then(|| roster.iter().find(free))
6947                .flatten()
6948        })
6949        .or_else(|| match carried {
6950            Some(c) => next_for_seat(roster, start, tried, c),
6951            None => next_untried_in_roster(roster, start, tried),
6952        })
6953}
6954
6955/// The next agent for a seat that carries its failure history across rounds
6956/// (the review loop). `round_tried` is this round's own bound and starts
6957/// empty every round; `carried_failed` only decides priority.
6958///
6959/// First: an id walking forward from `start`, never wrapping, that is neither
6960/// tried this round nor failed in an earlier one. Only when that is exhausted
6961/// does it rescue: the first roster id (in roster order, so this one *does*
6962/// look before `start`) not yet tried this round, which is by then a carried
6963/// failure. Each id is rescued at most once per round, so it cannot loop.
6964fn next_for_seat<'a>(
6965    roster: &'a [AgentSpec],
6966    start: usize,
6967    round_tried: &BTreeSet<String>,
6968    carried_failed: &BTreeSet<String>,
6969) -> Option<&'a AgentSpec> {
6970    roster
6971        .get(start + 1..)?
6972        .iter()
6973        .find(|s| !round_tried.contains(&s.id) && !carried_failed.contains(&s.id))
6974        .or_else(|| roster.iter().find(|s| !round_tried.contains(&s.id)))
6975}
6976
6977/// Where a reviewer seat starts a round: the agent that last answered it when
6978/// it is still on the roster and not marked failed, else the spec's own agent
6979/// unless it failed, else the next roster agent that has not failed, else the
6980/// spec's own agent again (the whole roster failed). Ids no longer on the
6981/// roster are ignored. An empty roster has no handover, so the spec stands.
6982fn pick_start_spec(roster: &[AgentSpec], spec: AgentSpec, hist: Option<&SeatHistory>) -> AgentSpec {
6983    let Some(h) = hist.filter(|_| !roster.is_empty()) else {
6984        return spec;
6985    };
6986    let ok = |id: &str| !h.failed.contains(id);
6987    if let Some(last) = h.last_ok.as_deref()
6988        && ok(last)
6989        && let Some(s) = roster.iter().find(|s| s.id == last)
6990    {
6991        return s.clone();
6992    }
6993    if ok(&spec.id) {
6994        return spec;
6995    }
6996    let from = roster.iter().position(|s| s.id == spec.id).unwrap_or(0);
6997    roster
6998        .get(from + 1..)
6999        .into_iter()
7000        .flatten()
7001        .chain(roster.iter())
7002        .find(|s| ok(&s.id))
7003        .cloned()
7004        .unwrap_or(spec)
7005}
7006
7007/// A fresh seat for the agent taking over `key`. Mixes the agent id into the
7008/// seed so a CLI that mints its session id up front (`--session-id`) never
7009/// reuses the uuid the previous agent already opened under the same seat key.
7010pub(crate) fn handover_seat(key: &str, agent: &str, run_seed: u64) -> SeatState {
7011    SeatState::new(key, agent, run_seed ^ crate::rng::fnv1a(agent))
7012}
7013
7014/// What an agent's turn timed out as, in [`AgentOutcome::Failed`]. One const
7015/// so the classifier below and the code that builds the message cannot drift.
7016const TIMED_OUT: &str = "timed out";
7017
7018impl FailClass {
7019    /// `None` for an answer; otherwise how the turn failed.
7020    fn of(out: &AgentOutcome) -> Option<Self> {
7021        match out {
7022            AgentOutcome::Ok(_) => None,
7023            AgentOutcome::Quota(_) => Some(Self::Quota),
7024            AgentOutcome::Dropped(_) => Some(Self::Other("dropped".to_owned())),
7025            AgentOutcome::Failed(e) if e == TIMED_OUT => Some(Self::Timeout),
7026            AgentOutcome::Failed(e) => Some(Self::Other(failure_signature(e))),
7027        }
7028    }
7029
7030    /// The word in a handover's artifact stem (`impl-A-quota-beta`).
7031    fn stem_word(&self) -> &'static str {
7032        match self {
7033            Self::Quota => "quota",
7034            _ => "handover",
7035        }
7036    }
7037}
7038
7039/// The message's first line with its variable parts removed — digit runs and
7040/// path-like tokens — so "exited with Some(2)" and "exited with Some(7)" read
7041/// as one kind of failure.
7042fn failure_signature(msg: &str) -> String {
7043    let line = msg.lines().next().unwrap_or("").trim().to_lowercase();
7044    let mut out = Vec::new();
7045    for word in line.split_whitespace() {
7046        if word.contains('/') || word.contains('\\') {
7047            out.push("<path>".to_owned());
7048            continue;
7049        }
7050        let mut w = String::new();
7051        let mut in_digits = false;
7052        for c in word.chars() {
7053            if c.is_ascii_digit() {
7054                if !in_digits {
7055                    w.push('#');
7056                }
7057                in_digits = true;
7058            } else {
7059                in_digits = false;
7060                w.push(c);
7061            }
7062        }
7063        out.push(w);
7064    }
7065    out.join(" ").chars().take(120).collect()
7066}
7067
7068/// Whether a seat that just failed with `cur` may go to the next roster agent.
7069/// A quota or a timeout always may. Any other failure may not when the agent
7070/// before it failed the same way: an error the prompt causes would otherwise
7071/// walk the whole roster. `prev` is the class of the immediately preceding
7072/// agent's failure, so a quota or timeout in between breaks the run of
7073/// identical failures by itself.
7074fn should_hand_over(prev: Option<&FailClass>, cur: &FailClass) -> bool {
7075    match cur {
7076        FailClass::Quota | FailClass::Timeout => true,
7077        FailClass::Other(_) => prev != Some(cur),
7078    }
7079}
7080
7081/// A short human reason for a failed outcome, for the handover record.
7082fn fail_reason(out: &AgentOutcome) -> String {
7083    match out {
7084        AgentOutcome::Ok(_) => String::new(),
7085        AgentOutcome::Quota(_) => "rate limited (quota)".to_owned(),
7086        AgentOutcome::Dropped(o) => format!(
7087            "the CLI dropped the stream ({})",
7088            o.dropped
7089                .as_ref()
7090                .map(|d| d.why.as_str())
7091                .unwrap_or("it ended without delivering its answer")
7092        ),
7093        AgentOutcome::Failed(e) => e.lines().next().unwrap_or("").chars().take(160).collect(),
7094    }
7095}
7096
7097/// Note one handover in the run: the structured record and, in the timeline,
7098/// the sentence a person reads. A quota keeps the wording it always had.
7099pub(crate) fn record_handover(
7100    state: &mut RunState,
7101    node: &str,
7102    seat: &str,
7103    from: &str,
7104    to: &str,
7105    class: &FailClass,
7106    reason: &str,
7107) {
7108    let message = if *class == FailClass::Quota {
7109        format!("{seat}: rate limited (quota) on {from}; retrying with {to}")
7110    } else {
7111        format!("{seat}: handed over {from} -> {to} ({reason})")
7112    };
7113    state.event(node, message);
7114    state.handovers.push(Handover {
7115        at: Timestamp::now(),
7116        node: node.to_owned(),
7117        seat: seat.to_owned(),
7118        from: from.to_owned(),
7119        to: to.to_owned(),
7120        reason: reason.to_owned(),
7121    });
7122}
7123
7124/// Did this reply report running a command whose own CLI never confirmed an
7125/// exit status?
7126///
7127/// An [`agent::CommandEvidence`] only ever exists when the CLI reported the
7128/// command *finished* (see that type's own doc), so this can only be `true`
7129/// for a command whose completion event carried no readable exit code — not
7130/// for one that simply is not mentioned at all. That is the one signal this
7131/// crate can read, from the same record `state.jobs` renders, about a reply
7132/// standing next to work its own CLI cannot vouch for finishing; it is
7133/// deliberately not a check on the exit code's *value* (a fixer legitimately
7134/// runs a command that fails mid-iteration before it succeeds) and not a
7135/// guess at a command still running in the background (which emits no event
7136/// at all, and so leaves no evidence here to find).
7137fn has_unconfirmed_command(commands: &[agent::CommandEvidence]) -> bool {
7138    commands.iter().any(|c| c.exit_code.is_none())
7139}
7140
7141/// Whether a `NO CHANGE NEEDED` marker in an implementer's reply should be
7142/// trusted as a verified no-op — the adoption guard's own text-level half.
7143///
7144/// `usable` is the caller's `AgentOutput::usable()` (a clean CLI exit, not
7145/// timed out): a marker only earns the benefit of the doubt from a turn the
7146/// CLI itself vouches for finishing properly, the same house style
7147/// `resume_unconfirmed_commands` and `continue_fix_report` already hold a
7148/// *fix* report to for `commands`. A candidate that timed out, exited
7149/// non-zero, or left a command unconfirmed is read as the ordinary loss it
7150/// is, whatever prose it wrote — this returns `None` before it ever looks at
7151/// `text`. The remaining guards (the tree really is empty, the evidence is
7152/// non-empty) are the caller's: this only reads what the reply *claimed*.
7153fn verified_noop_claim(
7154    usable: bool,
7155    commands: &[agent::CommandEvidence],
7156    text: &str,
7157) -> Option<String> {
7158    (usable && !has_unconfirmed_command(commands))
7159        .then(|| verdict::verified_noop(text))
7160        .flatten()
7161}
7162
7163fn short(commit: &str) -> String {
7164    commit.chars().take(7).collect()
7165}
7166
7167fn make_executable(path: &Path) -> Result<()> {
7168    #[cfg(unix)]
7169    {
7170        use std::os::unix::fs::PermissionsExt as _;
7171        let mut perms = std::fs::metadata(path)?.permissions();
7172        perms.set_mode(0o755);
7173        std::fs::set_permissions(path, perms)?;
7174    }
7175    #[cfg(not(unix))]
7176    {
7177        let _ = path;
7178    }
7179    Ok(())
7180}
7181
7182/// What every seat in one batch shares: where the answers are attributed, the
7183/// prompt overlay they inherit, and the build cache they are told to use.
7184///
7185/// A struct rather than four more parameters: `wave` also needs the run's
7186/// state (to record who is answering right now) and the attempt number, and
7187/// eight positional arguments is both unreadable and a clippy error.
7188struct WaveCtx<'a> {
7189    /// Exported as `MAGI_RUN`, so a task an agent files names the run that
7190    /// paid for it.
7191    run: &'a str,
7192    /// Exported as `MAGI_NODE`, and the key the prompt overlay is chosen by.
7193    node: &'a str,
7194    prompts: &'a Prompts,
7195    /// The shared `CARGO_TARGET_DIR`, when the config declares one.
7196    cache: Option<&'a Path>,
7197    /// The review round this wave belongs to, for `"review"`/`"fix"` — see
7198    /// `JobRecord::round`. `None` for every other node.
7199    round: Option<usize>,
7200    /// Carry each seat's failed-agent history across waves (the review loop
7201    /// only): start-of-round priority and handover choice read
7202    /// [`RunState::seat_history`], and every answer or failure writes it.
7203    carry_seats: bool,
7204}
7205
7206/// Run one job, honouring the parallelism budget.
7207async fn run_one(
7208    job: SeatJob,
7209    sem: Arc<Semaphore>,
7210    ctx: &WaveCtx<'_>,
7211    state: &mut RunState,
7212    attempt: usize,
7213) -> (SeatState, AgentOutcome) {
7214    let (_, seat, out) = wave(vec![job], sem, ctx, state, attempt)
7215        .await
7216        .pop()
7217        .expect("one job in, one result out");
7218    (seat, out)
7219}
7220
7221/// Run every job concurrently, capped by the semaphore, preserving order.
7222///
7223/// Every seat in the batch is recorded into [`RunState::active`] before the
7224/// wave starts and cleared as each answer lands, so the run's own record says
7225/// who is still being waited on rather than only who finished.
7226async fn wave(
7227    jobs: Vec<SeatJob>,
7228    sem: Arc<Semaphore>,
7229    ctx: &WaveCtx<'_>,
7230    state: &mut RunState,
7231    attempt: usize,
7232) -> Vec<(usize, SeatState, AgentOutcome)> {
7233    let WaveCtx {
7234        run,
7235        node,
7236        prompts,
7237        cache,
7238        round,
7239        carry_seats: _,
7240    } = *ctx;
7241    for job in &jobs {
7242        state.seat_started(node, &job.seat.key, job.timeout, attempt);
7243    }
7244    if let Err(e) = state.save() {
7245        // A failed persist of "who is answering right now" must not abort the
7246        // wave: the seats are already being asked, and the alternative is
7247        // losing the answers to save a status line nobody may even be
7248        // watching.
7249        tracing::warn!("could not persist in-progress seats: {e:#}");
7250    }
7251    // Hold the shared build cache's lease for the whole batch, not per job:
7252    // several candidates (an implement wave) or a fixer legitimately share
7253    // one cache concurrently within this run, and that stays untouched — a
7254    // single lease taken once for the whole wave and released once it is
7255    // done is what stops a *different* borrower (another run's own wave, its
7256    // e2e/gate, a human's `magi review`) from interleaving a build into the
7257    // same directory while this one is in flight. Best-effort, not
7258    // all-or-nothing: a wave that cannot get the lease within its own
7259    // longest job's budget still runs — an hour of paid implementer calls is
7260    // not thrown away over cache contention — but every write-allowed seat
7261    // then goes without `CARGO_TARGET_DIR` for this wave too (see the filter
7262    // below), the same fallback a read-only seat always gets, rather than
7263    // building into a directory this run was never granted. The identity
7264    // record is still invalidated below either way, so the next tracked
7265    // caller (`e2e`/`gate`) never trusts a match it cannot vouch for.
7266    let jobs_had_a_writer = jobs.iter().any(|j| j.allow_write);
7267    let wait_started = Instant::now();
7268    let cache_guard = if let Some(cache_dir) = cache {
7269        if jobs_had_a_writer {
7270            let owner = crate::cache::Owner::here(run, node, "*", Path::new("(wave)"), "");
7271            let budget = jobs
7272                .iter()
7273                .map(|j| j.timeout)
7274                .max()
7275                .unwrap_or(Duration::from_secs(60));
7276            acquire_cache_lease(state, cache_dir, &owner, budget, node)
7277                .await
7278                .ok()
7279        } else {
7280            None
7281        }
7282    } else {
7283        None
7284    };
7285    // Carved out of each job's own budget, not added on top of it: a seat
7286    // that waited behind the lease must not also get its full timeout
7287    // afterward, or a run contended on the cache could double the time it
7288    // spends per wave. `saturating_sub` floors at zero rather than
7289    // wrapping - a job whose whole budget was spent waiting starts with
7290    // none left, which is the honest number, not a free minimum.
7291    let waited_for_lease = wait_started.elapsed();
7292    let mut set = tokio::task::JoinSet::new();
7293    let overlay = prompts.overlay(node);
7294    for (i, mut job) in jobs.into_iter().enumerate() {
7295        job.timeout = job.timeout.saturating_sub(waited_for_lease);
7296        job.prompt = prompt::with_overlay(job.prompt, overlay.clone());
7297        if cache.is_some() {
7298            job.prompt.push('\n');
7299            job.prompt
7300                .push_str(&prompt::build_cache_note(node, job.allow_write));
7301        }
7302        let sem = Arc::clone(&sem);
7303        let run = run.to_owned();
7304        let node = node.to_owned();
7305        // Only implementers were told about the task's attachments, so only
7306        // their seats get the directory widened for reading.
7307        let attachments = if node == "implement" {
7308            state.attachments.clone()
7309        } else {
7310            Vec::new()
7311        };
7312        // A read-only seat is never handed `CARGO_TARGET_DIR` — see
7313        // `prompt::build_cache_note`'s doc for why setting it anyway is
7314        // exactly how a sandboxed reviewer's write refusal got reported as a
7315        // defect in the patch, not a property of its own seat. And a
7316        // write-allowed one is handed it only when the lease above was
7317        // actually acquired: a wave that could not get it (`cache_guard` is
7318        // `None`, see its own comment) must not send seats to build into a
7319        // directory this run does not hold - that is the exact concurrent,
7320        // unmanaged-write race this module exists to prevent, not something
7321        // "proceeding anyway" is allowed to reintroduce.
7322        let cache = cache
7323            .filter(|_| job.allow_write && cache_guard.is_some())
7324            .map(Path::to_path_buf);
7325        set.spawn(async move {
7326            let _permit = sem.acquire().await;
7327            let mut seat = job.seat;
7328            let out = agent::invoke(
7329                &job.spec,
7330                &mut seat,
7331                &Invocation {
7332                    cwd: &job.cwd,
7333                    prompt: &job.prompt,
7334                    timeout: job.timeout,
7335                    allow_write: job.allow_write,
7336                    unsandboxed: false,
7337                    sessions: job.sessions,
7338                    artifacts: &job.artifacts,
7339                    stem: &job.stem,
7340                    run: &run,
7341                    node: &node,
7342                    cache_dir: cache.as_deref(),
7343                    attachments: &attachments,
7344                    writable: &[],
7345                },
7346            )
7347            .await;
7348            let out = match out {
7349                Ok(o) if o.usable() => AgentOutcome::Ok(o),
7350                Ok(o) if o.quota_exhausted() => AgentOutcome::Quota(o),
7351                // Billed work the CLI failed to hand over is not an ordinary
7352                // failure, but its text is the CLI's raw error JSON, not an
7353                // answer — `Dropped` keeps it out of `Ok` so a caller cannot
7354                // read it as one by forgetting to check. `usable()` is always
7355                // false here (dropped implies an empty response), so this has
7356                // to be checked before the catch-all `Failed` below or the
7357                // one shape this exists for is lost with the rest.
7358                Ok(o) if o.work_undelivered() => AgentOutcome::Dropped(o),
7359                Ok(o) if o.timed_out => AgentOutcome::Failed(TIMED_OUT.to_owned()),
7360                Ok(o) => AgentOutcome::Failed(format!(
7361                    "exited with {:?} and no usable output",
7362                    o.exit_code
7363                )),
7364                Err(e) => AgentOutcome::Failed(e.to_string()),
7365            };
7366            (i, seat, out)
7367        });
7368    }
7369    let mut collected: Vec<Option<(usize, SeatState, AgentOutcome)>> = Vec::new();
7370    while let Some(joined) = set.join_next().await {
7371        let (i, seat, out) = match joined {
7372            Ok(v) => v,
7373            // No seat to clear: a panicked task never reported which one it
7374            // was. The defensive sweep below this loop is what stops that
7375            // seat's `active` entry from surviving forever.
7376            Err(e) => {
7377                tracing::error!("agent task panicked: {e}");
7378                continue;
7379            }
7380        };
7381        state.seat_finished(&seat.key);
7382        record_jobs(state, node, round, &seat.key, &out);
7383        if let Err(e) = state.save() {
7384            tracing::warn!("could not persist a seat's completion: {e:#}");
7385        }
7386        if collected.len() <= i {
7387            collected.resize_with(i + 1, || None);
7388        }
7389        collected[i] = Some((i, seat, out));
7390    }
7391    // Belt-and-braces for the panic branch above: every seat this exact batch
7392    // started shares this `(node, attempt)` pair, and every seat that finished
7393    // normally already cleared itself, so anything left tagged with it here
7394    // can only be a panicked task's leftover. Cleared unconditionally rather
7395    // than left to read as still answering forever.
7396    if state
7397        .active
7398        .values()
7399        .any(|a| a.node == node && a.attempt == attempt)
7400    {
7401        state
7402            .active
7403            .retain(|_, a| !(a.node == node && a.attempt == attempt));
7404        if let Err(e) = state.save() {
7405            tracing::warn!("could not persist the end of a wave: {e:#}");
7406        }
7407    }
7408    // Whether or not the lease above was actually held, several worktrees
7409    // may just have built into the cache with nothing here able to name one
7410    // coherent (worktree, head) for it - see `cache::invalidate_identity`'s
7411    // own doc. Forgetting the old record costs the next `e2e`/`gate` one
7412    // clean it might not have strictly needed; trusting a stale match would
7413    // cost it a wrong answer.
7414    if let Some(cache_dir) = cache
7415        && jobs_had_a_writer
7416    {
7417        crate::cache::invalidate_identity(&crate::run::home(), cache_dir);
7418    }
7419    if let Some(guard) = cache_guard {
7420        guard.release();
7421    }
7422    collected.into_iter().flatten().collect()
7423}
7424
7425/// Fold one seat's [`agent::CommandEvidence`] (if its outcome carries any)
7426/// into the run's [`JobRecord`] log — every node, every seat, uniformly:
7427/// this is data collection, not the fix-specific completion contract in
7428/// [`Runner::continue_fix_report`], and applies regardless of which node
7429/// asked.
7430///
7431/// Only `AgentOutcome::Ok`/`Quota`/`Dropped` carry an [`AgentOutput`] to read
7432/// evidence from; `Failed` does not, and correctly contributes nothing — a
7433/// timeout or crash is not itself evidence about a command the seat may have
7434/// started.
7435fn record_jobs(
7436    state: &mut RunState,
7437    node: &str,
7438    round: Option<usize>,
7439    seat: &str,
7440    out: &AgentOutcome,
7441) {
7442    let commands: &[agent::CommandEvidence] = match out {
7443        AgentOutcome::Ok(o) | AgentOutcome::Quota(o) | AgentOutcome::Dropped(o) => &o.commands,
7444        AgentOutcome::Failed(_) => &[],
7445    };
7446    let checked_at = Timestamp::now();
7447    for c in commands {
7448        state.jobs.push(JobRecord {
7449            node: node.to_owned(),
7450            round,
7451            seat: seat.to_owned(),
7452            id: c.id.clone(),
7453            description: c.description.clone(),
7454            checked_at,
7455            status: match c.exit_code {
7456                Some(0) => JobStatus::Completed,
7457                Some(_) => JobStatus::Failed,
7458                None => JobStatus::Unknown,
7459            },
7460            exit_code: c.exit_code,
7461            result_summary: c.result_summary.clone(),
7462            source: c.source.clone(),
7463        });
7464    }
7465}
7466
7467/// Is a review round clean, given how many reviewer seats answered against
7468/// how many the round expected?
7469///
7470/// A seat that never answered (timeout, crash, unparsable output) is not a
7471/// seat that read the patch and found nothing — treating it as such is
7472/// exactly the bug this function exists to close. Under the default `block`
7473/// policy a missing seat can never be clean; `warn` still requires the seats
7474/// that *did* answer to have found nothing blocking and verification to be
7475/// green.
7476///
7477/// `quota_missing` narrows that `block` default for exactly one cause of
7478/// absence: a seat lost to its own rate limit this round. Re-reviewing hoping
7479/// a session limit lifts by the very next round buys nothing — the seat is
7480/// asked again with the same quota — so once every missing seat is accounted
7481/// for by a quota loss (and at least one seat *did* answer, so a decision has
7482/// something to rest on) the round is decided on the panel that could answer,
7483/// same as `warn` would. A panel that lost every seat to quota is not
7484/// decided here: `answered == 0` falls through to the existing `block`
7485/// fallback so a fully collapsed panel still waits rather than landing on no
7486/// review at all.
7487fn round_is_clean(
7488    blocking: usize,
7489    e2e_ok: bool,
7490    answered: usize,
7491    expected: usize,
7492    quota_missing: usize,
7493    policy: IncompleteReviewPolicy,
7494) -> bool {
7495    if blocking != 0 || !e2e_ok {
7496        return false;
7497    }
7498    if answered == expected || policy == IncompleteReviewPolicy::Warn {
7499        return true;
7500    }
7501    answered > 0 && expected - answered <= quota_missing
7502}
7503
7504/// The review loop's own conclusion, derived entirely from its persisted
7505/// round records and the round budget that produced them — never from
7506/// `status`, so a reentry (or `gate`/`merge` reading it independently)
7507/// recomputes the identical answer regardless of what an earlier node in the
7508/// same walk, or a previous walk, did to `status`.
7509///
7510/// `None` while more rounds remain to try, including when review never ran
7511/// at all (`review_rounds = 0`, or nothing yet recorded). Once a round has
7512/// gone clean, or the budget is spent, or the tree has stopped moving (see
7513/// [`STAGNANT_LIMIT`]), the answer is one of two things:
7514///
7515/// - An incomplete panel that raised nothing is missing input, not a
7516///   verified tree — never a hand-off candidate, whatever verification said
7517///   (see [`ReviewRound::incomplete`], `IncompleteReviewPolicy`).
7518/// - Otherwise, green e2e on the last round hands off (see
7519///   [`Runner::stop_reviewing`]); red e2e blocks.
7520///
7521/// A last round whose own verification is still `ResourceBlocked` — magi
7522/// itself never got a command to run, not evidence the patch is broken —
7523/// is neither: this returns `None` for it too, the same as "more rounds
7524/// remain", so a reentry retries the check (see `Runner::review_loop`'s own
7525/// handling of that shape) instead of this cheap recomputation guessing a
7526/// verdict a real attempt never produced.
7527fn review_conclusion(reviews: &[ReviewRound], max_rounds: usize) -> Option<RunStatus> {
7528    if max_rounds == 0 || reviews.iter().any(|r| r.clean) {
7529        return Some(RunStatus::Gating);
7530    }
7531    let last = reviews.last()?;
7532    let stagnant = reviews.iter().rev().take_while(|r| !r.progressed).count() >= STAGNANT_LIMIT;
7533    if reviews.len() < max_rounds && !stagnant {
7534        return None;
7535    }
7536    if last.incomplete() && last.blocking == 0 {
7537        return Some(RunStatus::Blocked);
7538    }
7539    if last.e2e_status() == E2eStatus::ResourceBlocked {
7540        return None;
7541    }
7542    Some(if last.e2e.iter().all(CommandOutcome::ok) {
7543        RunStatus::Gating
7544    } else {
7545        RunStatus::Blocked
7546    })
7547}
7548
7549/// How long a re-ask may take, given the budget the first attempt had.
7550///
7551/// A `nudged` retry is a request to restate an answer the seat has already
7552/// worked out: it carries no new work, so it does not deserve the original
7553/// budget. Measured on run 01c2, two judges restated their ranking in 41 and
7554/// 133 seconds while a third sat for over ten minutes on a resumed session
7555/// holding 410 KB of prior output - and because the retry had inherited the
7556/// full 1200s judge timeout, one stuck nudge nearly doubled the wall time of a
7557/// judging round whose other seats were long finished.
7558///
7559/// A quarter of the budget, with a floor so that a deliberately short timeout
7560/// does not collapse to nothing. A retry that re-sends the whole prompt
7561/// (because the seat kept no context) is the original job again, and keeps the
7562/// original budget.
7563pub(crate) fn retry_budget(full: Duration, nudged: bool) -> Duration {
7564    if nudged {
7565        (full / 4).max(Duration::from_secs(120)).min(full)
7566    } else {
7567        full
7568    }
7569}
7570
7571/// Run a wave and parse each reply, re-asking the seats whose reply was
7572/// unusable.
7573///
7574/// The re-ask is a nudge rather than the whole prompt again when the seat still
7575/// holds its conversation, which is the difference between a cheap retry and
7576/// paying for the entire candidate set twice.
7577///
7578/// A seat whose agent *fails* (rate limit, timeout, any other error) and has a
7579/// successor in `roster` is handed to it instead of being re-asked: the
7580/// handover is the retry. A seat with no successor left (a single-agent
7581/// roster, the roster's tail) is nudged as before, up to `retries` times. So
7582/// the asks to one seat in one node number at most
7583/// `roster.len().max(1) * (1 + retries)`; an agent that still has a successor
7584/// is asked once (a dropped stream is nudged first), and only the last agent
7585/// of the chain gets the `retries` same-agent nudges. Each roster agent is
7586/// tried at most once per seat, walking forward from the seat's own position and never wrapping
7587/// ([`next_untried_in_roster`]); a quota or timeout always hands over, any
7588/// other failure stops the chain when the previous agent failed the same way
7589/// ([`should_hand_over`]). The new agent takes a fresh [`SeatState`], so
7590/// [`has_context`] is false and the job's own full prompt and full budget are
7591/// sent. A seat whose chain ends on a quota records one [`QuotaLoss`] (the
7592/// intermediate ones are not losses) and is returned as a failure like any
7593/// other absent seat — the caller decides whether the panel still has a
7594/// quorum. An empty `roster` disables handover: failures are nudged as they
7595/// always were, and a quota is simply lost. A reply that fails to parse or
7596/// validate is the prompt's doing and is only ever nudged, never handed over.
7597///
7598/// The returned [`SeatState`] names the agent that answered (or tried last).
7599#[allow(clippy::too_many_arguments)]
7600async fn ask_json_wave<T>(
7601    jobs: Vec<SeatJob>,
7602    sem: Arc<Semaphore>,
7603    retries: usize,
7604    roster: &[AgentSpec],
7605    ctx: &WaveCtx<'_>,
7606    losses: &mut Vec<QuotaLoss>,
7607    state: &mut RunState,
7608    validate: &(dyn Fn(&T) -> Result<()> + Send + Sync),
7609) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7610where
7611    T: serde::de::DeserializeOwned + Send + 'static,
7612{
7613    ask_wave_with(
7614        jobs,
7615        sem,
7616        retries,
7617        roster,
7618        ctx,
7619        losses,
7620        state,
7621        &|text: &str| {
7622            let v = verdict::extract_json::<T>(text)?;
7623            validate(&v)?;
7624            Ok(v)
7625        },
7626    )
7627    .await
7628}
7629
7630/// [`ask_json_wave`] with the reading of an answer supplied by the caller, so
7631/// a node whose answer is prose (deliberation) shares the same handover,
7632/// failure classification, quota bookkeeping and bounds instead of a copy.
7633///
7634/// A seat handed to another roster agent is sent the job's `handover` prompt
7635/// (when it has one) rather than `prompt`: the new agent has no session, so a
7636/// resume-style prompt would be incomplete. That holds for the handover ask
7637/// and for every nudge to that agent whose `has_context` is false.
7638#[allow(clippy::too_many_arguments)]
7639async fn ask_wave_with<T>(
7640    jobs: Vec<SeatJob>,
7641    sem: Arc<Semaphore>,
7642    retries: usize,
7643    roster: &[AgentSpec],
7644    ctx: &WaveCtx<'_>,
7645    losses: &mut Vec<QuotaLoss>,
7646    state: &mut RunState,
7647    parse: &(dyn Fn(&str) -> Result<T> + Send + Sync),
7648) -> Vec<(SeatState, Result<(T, AgentOutput)>, usize)>
7649where
7650    T: Send + 'static,
7651{
7652    let n = jobs.len();
7653    let originals: Vec<SeatJob> = jobs;
7654    let mut seats: Vec<SeatState> = originals.iter().map(|j| j.seat.clone()).collect();
7655    let mut done: Vec<Option<Result<(T, AgentOutput)>>> = (0..n).map(|_| None).collect();
7656    // Nudges each seat's *current* agent has taken — 0 for a first-ask
7657    // answer, N once it has gone through N nudges. Read back once this
7658    // returns, so a caller building a history record (`ReviewRecord`) can
7659    // tell "never answered" (`failed: Some(_)`, `attempts == 0`) apart from
7660    // "recovered after a nudge" (`failed: None`, `attempts > 0`) — see that
7661    // field's own doc.
7662    let mut nudges: Vec<usize> = vec![0; n];
7663    // The agent now occupying each seat, the ids it has already been through,
7664    // where in the roster the walk began, the class of the last failure, and
7665    // the stem word of a handover not yet asked (full prompt, full budget).
7666    let mut specs: Vec<AgentSpec> = originals.iter().map(|j| j.spec.clone()).collect();
7667    let mut tried: Vec<BTreeSet<String>> = specs
7668        .iter()
7669        .map(|s| BTreeSet::from([s.id.clone()]))
7670        .collect();
7671    let starts: Vec<usize> = specs
7672        .iter()
7673        .map(|s| roster.iter().position(|r| r.id == s.id).unwrap_or(0))
7674        .collect();
7675    let carry = ctx.carry_seats && !roster.is_empty();
7676    // Carried across rounds: ids that failed the seat earlier, and how the
7677    // last failure went (so a repeat of it is not handed over again).
7678    let carried: Vec<BTreeSet<String>> = originals
7679        .iter()
7680        .map(|j| {
7681            state
7682                .seat_history
7683                .get(&j.seat.key)
7684                .filter(|_| carry)
7685                .map(|h| h.failed.clone())
7686                .unwrap_or_default()
7687        })
7688        .collect();
7689    let mut prev: Vec<Option<FailClass>> = originals
7690        .iter()
7691        .map(|j| {
7692            state
7693                .seat_history
7694                .get(&j.seat.key)
7695                .filter(|_| carry)
7696                .and_then(|h| h.last_fail.clone())
7697        })
7698        .collect();
7699    let next_agent =
7700        |i: usize, tried: &BTreeSet<String>, specs: &[AgentSpec]| -> Option<AgentSpec> {
7701            let others: BTreeSet<String> = specs
7702                .iter()
7703                .enumerate()
7704                .filter(|(j, _)| *j != i)
7705                .map(|(_, s)| s.id.clone())
7706                .collect();
7707            pick_successor(
7708                roster,
7709                starts[i],
7710                tried,
7711                carry.then(|| &carried[i]),
7712                &others,
7713            )
7714            .cloned()
7715        };
7716    let mut fresh: Vec<Option<String>> = vec![None; n];
7717    let mut last_quota: Vec<Option<Option<String>>> = vec![None; n];
7718    let mut pending: Vec<usize> = (0..n).collect();
7719
7720    // Per seat the work is bounded by `roster.len().max(1) * (1 + retries)`
7721    // asks: an agent with a successor is asked once and handed over, and only
7722    // a seat with no successor spends `retries` nudges on the same agent. This
7723    // only guarantees the loop's own termination whatever those say.
7724    let max_rounds = (retries + 1) * roster.len().max(1) + 1;
7725    for round in 0..max_rounds {
7726        if pending.is_empty() {
7727            break;
7728        }
7729        let mut batch = Vec::with_capacity(pending.len());
7730        let mut renudged: Vec<&str> = Vec::new();
7731        for &i in &pending {
7732            let src = &originals[i];
7733            // A seat now held by another agent than the job named has no
7734            // session of its own: it gets the full-context prompt whenever
7735            // it is asked in full (the handover ask, a nudge it cannot
7736            // resume).
7737            let full: &str = match &src.handover {
7738                Some(h) if specs[i].id != src.spec.id => h,
7739                _ => &src.prompt,
7740            };
7741            // The prompt and the budget are one decision: a nudge restates
7742            // finished work, a re-sent prompt redoes it.
7743            let (prompt, timeout, stem) = if let Some(word) = fresh[i].take() {
7744                (
7745                    full.to_owned(),
7746                    src.timeout,
7747                    format!("{}-{word}-{}", src.stem, specs[i].id),
7748                )
7749            } else if nudges[i] == 0 {
7750                (full.to_owned(), src.timeout, src.stem.clone())
7751            } else {
7752                renudged.push(src.seat.key.as_str());
7753                let why = done[i]
7754                    .as_ref()
7755                    .and_then(|r| r.as_ref().err().map(ToString::to_string))
7756                    .unwrap_or_else(|| "no parsable answer".to_owned());
7757                let nudge = prompt::nudge(&why);
7758                let nudged = has_context(&specs[i], &seats[i], src.sessions);
7759                let prompt = if nudged {
7760                    nudge
7761                } else {
7762                    format!("{full}\n\n---\n\n{nudge}")
7763                };
7764                (
7765                    prompt,
7766                    retry_budget(src.timeout, nudged),
7767                    format!("{}-retry{}", src.stem, nudges[i]),
7768                )
7769            };
7770            batch.push(SeatJob {
7771                spec: specs[i].clone(),
7772                seat: seats[i].clone(),
7773                cwd: src.cwd.clone(),
7774                prompt,
7775                timeout,
7776                allow_write: src.allow_write,
7777                sessions: src.sessions,
7778                artifacts: src.artifacts.clone(),
7779                stem,
7780                handover: None,
7781            });
7782        }
7783
7784        if !renudged.is_empty() {
7785            state.event(
7786                ctx.node,
7787                format!("retry {round}: re-asking {}", renudged.join(", ")),
7788            );
7789        }
7790        let results = wave(batch, Arc::clone(&sem), ctx, state, round).await;
7791        let mut still = Vec::new();
7792        for (&i, (_wi, seat, out)) in pending.iter().zip(results) {
7793            seats[i] = seat;
7794            let class = FailClass::of(&out);
7795            // A dropped stream is nudged first (the conversation is still
7796            // there to pick up); only a seat whose nudges are spent hands over.
7797            let nudge_first = matches!(out, AgentOutcome::Dropped(_))
7798                && nudges[i] < retries
7799                && !roster.is_empty();
7800            if let Some(cur) = class.clone().filter(|_| !roster.is_empty() && !nudge_first) {
7801                let next = should_hand_over(prev[i].as_ref(), &cur)
7802                    .then(|| next_agent(i, &tried[i], &specs))
7803                    .flatten();
7804                if carry {
7805                    let h = state
7806                        .seat_history
7807                        .entry(originals[i].seat.key.clone())
7808                        .or_default();
7809                    h.failed.insert(specs[i].id.clone());
7810                    h.last_fail = Some(cur.clone());
7811                    if h.last_ok.as_deref() == Some(specs[i].id.as_str()) {
7812                        h.last_ok = None;
7813                    }
7814                    // Saved before the next agent is asked, so a restart in
7815                    // between does not forget who failed.
7816                    if let Err(e) = state.save() {
7817                        tracing::warn!("could not persist a seat's failure history: {e:#}");
7818                    }
7819                }
7820                if let Some(next) = next {
7821                    record_handover(
7822                        state,
7823                        ctx.node,
7824                        &originals[i].seat.key,
7825                        &specs[i].id,
7826                        &next.id,
7827                        &cur,
7828                        &fail_reason(&out),
7829                    );
7830                    tried[i].insert(next.id.clone());
7831                    prev[i] = Some(cur.clone());
7832                    seats[i] =
7833                        handover_seat(&originals[i].seat.key, &next.id, state.next_seat_seed());
7834                    specs[i] = next;
7835                    fresh[i] = Some(cur.stem_word().to_owned());
7836                    nudges[i] = 0;
7837                    done[i] = Some(Err(anyhow::anyhow!(
7838                        "handed over after: {}",
7839                        fail_reason(&out)
7840                    )));
7841                    still.push(i);
7842                    continue;
7843                }
7844            }
7845            if carry
7846                && !nudge_first
7847                && let Some(cur) = class.clone()
7848            {
7849                // The chain ended here (no successor, or a repeated failure).
7850                let h = state
7851                    .seat_history
7852                    .entry(originals[i].seat.key.clone())
7853                    .or_default();
7854                h.failed.insert(specs[i].id.clone());
7855                h.last_fail = Some(cur);
7856            }
7857            let parsed = match out {
7858                AgentOutcome::Ok(o) => parse(&o.text).map(|v| (v, o)),
7859                AgentOutcome::Quota(o) => {
7860                    last_quota[i] = Some(o.quota.as_ref().and_then(|q| q.reset.clone()));
7861                    Err(anyhow::anyhow!("rate limited (quota); not retrying now"))
7862                }
7863                // Not a parseable answer: the nudge loop re-asks it, which is
7864                // exactly what a dropped stream needs. Just don't hand its raw
7865                // error JSON to `extract_json`.
7866                AgentOutcome::Dropped(o) => {
7867                    let why = o
7868                        .dropped
7869                        .as_ref()
7870                        .map(|d| d.why.as_str())
7871                        .unwrap_or("the CLI ended the stream without delivering its answer");
7872                    Err(anyhow::anyhow!("the CLI dropped the stream ({why})"))
7873                }
7874                AgentOutcome::Failed(e) => Err(anyhow::anyhow!(e)),
7875            };
7876            let quota = class == Some(FailClass::Quota);
7877            let failed = parsed.is_err();
7878            done[i] = Some(parsed);
7879            if carry && !failed {
7880                let h = state
7881                    .seat_history
7882                    .entry(originals[i].seat.key.clone())
7883                    .or_default();
7884                h.failed.remove(&specs[i].id);
7885                h.last_ok = Some(specs[i].id.clone());
7886                h.last_fail = None;
7887            }
7888            // Do not re-ask a rate-limited seat (quota) — a retry is known to
7889            // fail the same way; and never re-ask a seat that already parsed.
7890            // A failed agent that still has a successor is not re-asked
7891            // either: the handover was its remedy and has just been refused
7892            // (the chain stops on a repeated failure class). A seat with no
7893            // successor left (a single-agent roster, the roster's tail, or an
7894            // empty roster) keeps the same-agent nudge, bounded by `retries`.
7895            let agent_failure = class.is_some()
7896                && !nudge_first
7897                && !roster.is_empty()
7898                && next_agent(i, &tried[i], &specs).is_some();
7899            if failed && !quota && !agent_failure && nudges[i] < retries {
7900                nudges[i] += 1;
7901                still.push(i);
7902            }
7903        }
7904        pending = still;
7905    }
7906
7907    // One loss per seat whose chain ended on a quota: the intermediate ones
7908    // were absorbed by a handover and are not losses.
7909    for (i, q) in last_quota.into_iter().enumerate() {
7910        if let Some(reset) = q {
7911            losses.push(QuotaLoss {
7912                seat: originals[i].seat.key.clone(),
7913                node: ctx.node.to_owned(),
7914                at: Timestamp::now(),
7915                reset,
7916            });
7917        }
7918    }
7919
7920    seats
7921        .into_iter()
7922        .zip(done)
7923        .zip(nudges)
7924        .map(|((seat, res), attempts)| {
7925            (
7926                seat,
7927                res.unwrap_or_else(|| Err(anyhow::anyhow!("no attempt was made"))),
7928                attempts,
7929            )
7930        })
7931        .collect()
7932}
7933
7934/// Acquire the shared build cache's lease, waiting out contention within
7935/// `budget` (never past it — see AGENTS.md's build-cache section on why an
7936/// unbounded wait is never acceptable).
7937///
7938/// A first, non-blocking check happens before ever waiting; if it finds the
7939/// lease busy, that fact is logged as a `verify` event *and* flushed with
7940/// [`RunState::save`] immediately — not only once the wait finally succeeds
7941/// or gives up — so a `magi show` run by a different process while this one
7942/// is still waiting reads a `run.json` that says so, rather than whatever it
7943/// looked like before the wait started. The same applies to the terminal
7944/// failure: logged and saved before this returns `Err`, so a caller that
7945/// could not get the lease at all still leaves a legible record of why.
7946async fn acquire_cache_lease(
7947    state: &mut RunState,
7948    cache_dir: &Path,
7949    owner: &crate::cache::Owner,
7950    budget: Duration,
7951    context: &str,
7952) -> Result<crate::cache::Guard> {
7953    let home = crate::run::home();
7954    let started = Instant::now();
7955    let busy = match crate::cache::try_acquire(&home, cache_dir, owner) {
7956        Ok(crate::cache::AcquireOutcome::Acquired(g)) => return Ok(g),
7957        Ok(crate::cache::AcquireOutcome::Busy(busy)) => busy,
7958        Err(e) => {
7959            state.event(
7960                "verify",
7961                format!("{context}: could not check the shared build cache: {e:#}"),
7962            );
7963            if let Err(e2) = state.save() {
7964                tracing::warn!("could not persist a cache-check failure: {e2:#}");
7965            }
7966            return Err(e);
7967        }
7968    };
7969    state.event(
7970        "verify",
7971        format!(
7972            "{context}: waiting for the shared build cache at {} ({})",
7973            cache_dir.display(),
7974            busy.describe()
7975        ),
7976    );
7977    if let Err(e) = state.save() {
7978        tracing::warn!("could not persist a cache wait: {e:#}");
7979    }
7980    let remaining = budget.saturating_sub(started.elapsed());
7981    match crate::cache::wait_for(&home, cache_dir, owner, remaining, Duration::from_secs(5)).await {
7982        Ok(g) => Ok(g),
7983        Err(e) => {
7984            state.event("verify", format!("{context}: {e:#}"));
7985            if let Err(e2) = state.save() {
7986                tracing::warn!("could not persist a cache wait timeout: {e2:#}");
7987            }
7988            Err(e)
7989        }
7990    }
7991}
7992
7993/// Run `body` — a verify command batch — while holding the shared build
7994/// cache's lease, so this run's own full verification (`e2e`, `gate`) can
7995/// never interleave with another borrower's build against the same
7996/// `CARGO_TARGET_DIR`: a different run, a lingering reviewer past its
7997/// timeout, or a human's own `magi review`. See the `cache` module doc for
7998/// why this matters more than Cargo's own per-target locking covers — two
7999/// *different* worktrees building the same package name/version into one
8000/// cache directory is a staleness bug, not a lock contention one.
8001///
8002/// The wait for the lease is carved out of `budget`, never on top of it —
8003/// `body` is handed whatever is left, so a caller's own node timeout is the
8004/// only clock involved, exactly what AGENTS.md's build-cache section asks
8005/// for ("never an unbounded wait"). When `cache_dir` is `None` — no shared
8006/// cache configured at all — this is a pass-through: `body` runs with the
8007/// full budget and nothing is leased.
8008///
8009/// A lease that cannot be acquired within `budget` is reported as a single
8010/// synthetic [`CommandOutcome`] (`code: None`) rather than silently skipping
8011/// verification — the same shape a spawn failure already takes in
8012/// [`run_commands`], so a caller need not special-case it.
8013#[allow(clippy::too_many_arguments)]
8014async fn with_cache_lease<'s, F, Fut>(
8015    state: &'s mut RunState,
8016    cache_dir: Option<&Path>,
8017    node: &str,
8018    seat: &str,
8019    worktree: &Path,
8020    head: &str,
8021    budget: Duration,
8022    context: &str,
8023    body: F,
8024) -> (Vec<CommandOutcome>, bool)
8025where
8026    F: FnOnce(&'s mut RunState, Duration) -> Fut,
8027    Fut: std::future::Future<Output = (Vec<CommandOutcome>, bool, Vec<u32>)>,
8028{
8029    let Some(cache_dir) = cache_dir else {
8030        let (outcomes, retried, _timed_out_pids) = body(state, budget).await;
8031        return (outcomes, retried);
8032    };
8033    let home = crate::run::home();
8034    let owner = crate::cache::Owner::here(&state.id, node, seat, worktree, head);
8035    let started = Instant::now();
8036    let guard = match acquire_cache_lease(state, cache_dir, &owner, budget, context).await {
8037        Ok(g) => g,
8038        Err(e) => {
8039            return (
8040                vec![CommandOutcome {
8041                    command: "(waiting for the shared build cache)".to_owned(),
8042                    code: None,
8043                    output_tail: e.to_string(),
8044                    duration_ms: started.elapsed().as_millis() as u64,
8045                    resource_blocked: true,
8046                }],
8047                false,
8048            );
8049        }
8050    };
8051    let identity = crate::cache::Identity::new(worktree, head);
8052    if let Err(e) = crate::cache::ensure_fresh(
8053        &home,
8054        cache_dir,
8055        &identity,
8056        &state.config.verify.cargo_manifests(),
8057    ) {
8058        // A failed freshness check means this process cannot vouch for what
8059        // is sitting in the cache right now - on Windows this is exactly the
8060        // "a stale test executable is still locked, `cargo clean -p` cannot
8061        // remove it" case the evidence log records. Running verify anyway
8062        // and reporting whatever it says would let a result nobody can trust
8063        // stand for the tree it claims to have checked; fail the step
8064        // instead of the patch.
8065        state.event(
8066            "verify",
8067            format!(
8068                "{context}: could not confirm the shared build cache matches {} at {}: {e:#}",
8069                worktree.display(),
8070                short(head)
8071            ),
8072        );
8073        guard.release();
8074        return (
8075            vec![CommandOutcome {
8076                command: "(confirming the shared build cache is fresh)".to_owned(),
8077                code: None,
8078                output_tail: e.to_string(),
8079                duration_ms: started.elapsed().as_millis() as u64,
8080                resource_blocked: true,
8081            }],
8082            false,
8083        );
8084    }
8085    let remaining = budget.saturating_sub(started.elapsed());
8086    let (outcomes, retried, timed_out_pids) = body(state, remaining).await;
8087    // A timed-out command's process was only *asked* to die (`kill_on_drop`,
8088    // `start_kill`); confirm it actually has before handing the directory to
8089    // the next acquirer. See `wait_for_timed_out_children_to_die`'s own doc
8090    // for what this can and cannot see.
8091    if !timed_out_pids.is_empty() {
8092        wait_for_timed_out_children_to_die(&timed_out_pids).await;
8093    }
8094    guard.release();
8095    (outcomes, retried)
8096}
8097
8098/// Poll `pids` — commands [`run_commands`] reports as still running when its
8099/// own timeout elapsed — until every one is confirmed gone, or
8100/// [`LEASE_RELEASE_MAX_WAIT`] passes, whichever comes first.
8101///
8102/// Real confirmation where confirmation is possible, not a substitute for
8103/// full process-tree observation: a grandchild the timed-out process spawned
8104/// and that survives independently of it is invisible to a pid check the
8105/// same way it always was, and continuing to observe and collect *that*
8106/// stays a different piece of work with its own owner. This only narrows a
8107/// fixed blind wait into an actual check of the pids this process does know
8108/// about.
8109async fn wait_for_timed_out_children_to_die(pids: &[u32]) {
8110    wait_for_pids_with(
8111        pids,
8112        crate::proc::pid_alive,
8113        LEASE_RELEASE_POLL,
8114        LEASE_RELEASE_MAX_WAIT,
8115    )
8116    .await;
8117}
8118
8119/// [`wait_for_timed_out_children_to_die`] with its liveness query, poll
8120/// interval and ceiling supplied by the caller, so the polling *logic* -
8121/// returns as soon as every pid reports dead, gives up at the ceiling
8122/// otherwise - is testable on millisecond durations without asking the real
8123/// OS about a pid at all.
8124async fn wait_for_pids_with<F: Fn(u32) -> bool>(
8125    pids: &[u32],
8126    alive: F,
8127    poll: Duration,
8128    max_wait: Duration,
8129) {
8130    let deadline = Instant::now() + max_wait;
8131    loop {
8132        if pids.iter().all(|&pid| !alive(pid)) {
8133            return;
8134        }
8135        if Instant::now() >= deadline {
8136            return;
8137        }
8138        tokio::time::sleep(poll).await;
8139    }
8140}
8141
8142/// Are any of `outcomes` [`CommandOutcome::resource_blocked`] - magi's own
8143/// admission that it could not even get a verify command to run, as opposed
8144/// to evidence the command actually produced? A caller that would otherwise
8145/// read a resource-blocked outcome as a red command must check this first:
8146/// see [`Runner::gate`], which retries rather than records `Blocked` when
8147/// this is true.
8148fn verify_inconclusive(outcomes: &[CommandOutcome]) -> bool {
8149    outcomes.iter().any(|o| o.resource_blocked)
8150}
8151
8152/// What [`Runner::gate_fix_round`] decided.
8153enum GateFix {
8154    /// The tree changed and `verify.e2e` is still green: run the gate again.
8155    Retry,
8156    /// No more rounds, nothing to fix, or the fix did not hold: the gate's
8157    /// last failure stands and the run ends blocked.
8158    Stop,
8159    /// `verify.e2e` could not run after the fix (magi's own contention):
8160    /// decide nothing now, a later reentry retries.
8161    Defer,
8162}
8163
8164/// Is every red command in `outcomes` an ordinary failure the code could
8165/// explain: it ran, exited non-zero, and said something?
8166///
8167/// A timeout, a spawn failure and a killed process all leave `code` `None`;
8168/// 126 / 127 are the POSIX shell's "cannot execute" / "not found". Output-free
8169/// exits carry nothing for a fixer to act on. Language-agnostic on purpose:
8170/// what the command is stays the gate's business.
8171fn gate_fixable(outcomes: &[CommandOutcome]) -> bool {
8172    let mut red = outcomes.iter().filter(|o| !o.ok()).peekable();
8173    red.peek().is_some()
8174        && red.all(|o| {
8175            !o.resource_blocked
8176                && matches!(o.code, Some(c) if c != 0 && c != 126 && c != 127)
8177                && !o.output_tail.trim().is_empty()
8178        })
8179}
8180
8181/// Describe one verify command's outcome for the event log, distinguishing a
8182/// build/link failure — the toolchain never produced a binary to run — from
8183/// an actual test failure, since only the latter is a verdict on the patch.
8184fn e2e_outcome_label(o: &CommandOutcome) -> String {
8185    if o.ok() {
8186        return "pass".to_owned();
8187    }
8188    let reason = if o.build_failed() {
8189        format!("COULD NOT RUN ({:?}, build/link failure)", o.code)
8190    } else {
8191        format!("FAIL ({:?})", o.code)
8192    };
8193    format!("{reason}\n{}", tail(&o.output_tail, EVENT_OUTPUT_TAIL))
8194}
8195
8196/// Run `verify.e2e`, retrying once if the first attempt could not build or
8197/// link — a build/link failure is frequently a race against a shared
8198/// `CARGO_TARGET_DIR` (see AGENTS.md), not a verdict on the patch. Emits one
8199/// `verify` event per command, tagged with `context` (normally `"round N"`)
8200/// so the two call sites that need this — the ordinary per-round leg in
8201/// `review_loop`, and the deferred catch-up run `stop_reviewing` makes before
8202/// it will ever call a round green — read identically in the event log.
8203async fn run_e2e_with_retry(
8204    state: &mut RunState,
8205    shell: &[String],
8206    commands: &[String],
8207    worktree: &Path,
8208    timeout: Duration,
8209    context: &str,
8210) -> (Vec<CommandOutcome>, bool, Vec<u32>) {
8211    let (mut e2e, mut timed_out_pids) = run_commands(
8212        state, "verify", "e2e", 0, shell, commands, worktree, timeout,
8213    )
8214    .await;
8215    for o in &e2e {
8216        state.event(
8217            "verify",
8218            format!("{context}: `{}` -> {}", o.command, e2e_outcome_label(o)),
8219        );
8220    }
8221    // A build/link failure is not a verdict on the patch — it is frequently a
8222    // race against a shared `CARGO_TARGET_DIR` (see AGENTS.md). Give verify
8223    // one retry before letting a red like that decide the round.
8224    let verify_retried = e2e.iter().any(CommandOutcome::build_failed);
8225    if verify_retried {
8226        state.event(
8227            "verify",
8228            format!(
8229                "{context}: verify could not build/link, not a test result — retrying once \
8230                 before concluding"
8231            ),
8232        );
8233        let retried = run_commands(
8234            state, "verify", "e2e", 1, shell, commands, worktree, timeout,
8235        )
8236        .await;
8237        e2e = retried.0;
8238        // Both attempts' timeouts matter, not just the last one: the first
8239        // attempt's descendants may still be alive alongside the retry's.
8240        timed_out_pids.extend(retried.1);
8241        for o in &e2e {
8242            state.event(
8243                "verify",
8244                format!(
8245                    "{context}: retry `{}` -> {}",
8246                    o.command,
8247                    e2e_outcome_label(o)
8248                ),
8249            );
8250        }
8251    }
8252    (e2e, verify_retried, timed_out_pids)
8253}
8254
8255/// Run configured shell commands in `cwd`, in order. The second element is
8256/// the pid of every command that hit `timeout` and was still running when
8257/// this stopped waiting on it (best-effort: `None` when the platform did not
8258/// hand one back) — see [`with_cache_lease`]'s use of it for why a caller
8259/// that releases a shared resource afterward needs to know.
8260///
8261/// Records `task` into [`RunState::active`] at every command boundary
8262/// (`RunState::task_command`) and clears it once the whole list has run
8263/// (`RunState::task_finished`) — a `verify.e2e` / `verify.gate` list can run
8264/// for minutes with no seat and no output of its own to show for it (see
8265/// `CommandOutcome`'s doc on why an empty `e2e`/`gate` alone cannot be told
8266/// apart from "not yet run" without this), and this is the only place that
8267/// knows which command is running right now and how many are left. Three
8268/// saves per command — start, not per second — matching the same "only at a
8269/// boundary" rule [`wave`] already follows for seats.
8270#[allow(clippy::too_many_arguments)]
8271async fn run_commands(
8272    state: &mut RunState,
8273    node: &str,
8274    task: &str,
8275    attempt: usize,
8276    shell: &[String],
8277    commands: &[String],
8278    cwd: &Path,
8279    timeout: Duration,
8280) -> (Vec<CommandOutcome>, Vec<u32>) {
8281    if commands.is_empty() {
8282        // Nothing to mark as running and nothing to clear — an empty list
8283        // means "not configured", and touching `active` (or the disk) over
8284        // that would be a write for every round of a repo with no
8285        // `verify.e2e` / `verify.gate` commands at all.
8286        return (Vec::new(), Vec::new());
8287    }
8288    let mut out = Vec::new();
8289    let mut timed_out_pids = Vec::new();
8290    let total = commands.len();
8291    for (idx, command) in commands.iter().enumerate() {
8292        state.task_command(task, node, attempt, command, idx + 1, total, timeout);
8293        if let Err(e) = state.save() {
8294            tracing::warn!("could not persist an in-progress {task} command: {e:#}");
8295        }
8296        let started = Instant::now();
8297        let mut cmd = tokio::process::Command::new(&shell[0]);
8298        cmd.quiet();
8299        cmd.args(&shell[1..])
8300            .arg(command)
8301            .current_dir(cwd)
8302            .stdin(std::process::Stdio::null())
8303            .stdout(std::process::Stdio::piped())
8304            .stderr(std::process::Stdio::piped())
8305            .kill_on_drop(true);
8306        let spawned = cmd.spawn();
8307        let (code, body) = match spawned {
8308            Ok(child) => {
8309                // Captured before the child is consumed below: `kill_on_drop`
8310                // only *asks* the process to die when the timeout branch
8311                // drops it, and the pid is the only way anyone downstream can
8312                // later check whether that request actually took.
8313                let pid = child.id();
8314                match tokio::time::timeout(timeout, child.wait_with_output()).await {
8315                    Ok(Ok(o)) => {
8316                        let mut body = String::from_utf8_lossy(&o.stdout).into_owned();
8317                        body.push_str(&String::from_utf8_lossy(&o.stderr));
8318                        (o.status.code(), body)
8319                    }
8320                    Ok(Err(e)) => (None, format!("failed to run: {e}")),
8321                    Err(_) => {
8322                        if let Some(pid) = pid {
8323                            timed_out_pids.push(pid);
8324                        }
8325                        (None, format!("timed out after {}s", timeout.as_secs()))
8326                    }
8327                }
8328            }
8329            Err(e) => (None, format!("failed to spawn `{}`: {e}", shell[0])),
8330        };
8331        out.push(CommandOutcome {
8332            command: command.clone(),
8333            code,
8334            output_tail: tail(&body, OUTPUT_TAIL),
8335            duration_ms: started.elapsed().as_millis() as u64,
8336            resource_blocked: false,
8337        });
8338    }
8339    state.task_finished(task);
8340    if let Err(e) = state.save() {
8341        tracing::warn!("could not persist the end of {task}: {e:#}");
8342    }
8343    (out, timed_out_pids)
8344}
8345
8346/// The shell command line `mode = "none"` prints — in `magi show`'s `merge`
8347/// section (`report::run`) and in the `merge` event this node records — for
8348/// the operator to run by hand.
8349///
8350/// Built from [`MergeStyle`] rather than always `git merge --no-ff`: a base
8351/// branch whose ruleset forbids merge commits (GitHub's "must not contain
8352/// merge commits", or "require linear history") rejects the push a `--no-ff`
8353/// merge would produce, which is exactly the guidance this function replaces.
8354/// `message`'s first line becomes the squash commit's subject, matching the
8355/// note `report::run` prints alongside this command — see that function for
8356/// why an explicit subject is not optional there.
8357fn manual_merge_command(style: MergeStyle, repo: &Path, branch: &str, message: &str) -> String {
8358    let repo = repo.display();
8359    match style {
8360        MergeStyle::Merge => format!("git -C {repo} merge --no-ff {branch}"),
8361        MergeStyle::Squash => {
8362            // The subject sits inside double quotes, and a title an agent
8363            // wrote may carry the characters that break out of them.
8364            let subject = message
8365                .lines()
8366                .next()
8367                .unwrap_or(branch)
8368                .replace(['\\', '"', '$', '`'], "");
8369            format!(
8370                "git -C {repo} merge --squash {branch} && git -C {repo} commit -m \"{subject}\""
8371            )
8372        }
8373        MergeStyle::Rebase => format!("git -C {repo} merge --ff-only {branch}"),
8374    }
8375}
8376
8377/// GitHub's `createPullRequest` GraphQL mutation, which `gh pr create` calls
8378/// under the hood, rejects a `title` over 256 characters and the whole
8379/// command fails — no PR at all, for a run whose body was otherwise fine
8380/// (this is what happened to run 2963; see AGENTS.md). 240 leaves room below
8381/// that limit: titles are counted in `chars()` (Unicode scalars), which is not
8382/// always how GitHub counts. [`english_title`] keeps its trailing `...` inside
8383/// this bound. It is a margin, not a guarantee — a title packed
8384/// with multi-unit characters could still in principle land close to the
8385/// edge, but a real task title's occasional emoji or accented letter fits
8386/// comfortably inside it.
8387const PR_TITLE_MAX: usize = 240;
8388
8389/// A pull request title from the opening line of `text`, or `None` when that
8390/// line is not English (GitHub text is) or has no letters.
8391///
8392/// A line within `max` is kept as is. A longer one is cut at the end of its
8393/// first sentence when that falls inside `max`, else at a word boundary with a
8394/// plain `...` (ASCII, unlike the `…` `queue::title_from` appends, which would
8395/// make every merely-truncated title look non-English). The language check
8396/// runs on the kept text before any mark is added, so only what GitHub will
8397/// show is judged: an English opening followed by non-ASCII far past the cut
8398/// still passes.
8399fn english_title(text: &str, max: usize) -> Option<String> {
8400    let line = queue::first_line(text)?;
8401    let chars: Vec<char> = line.chars().collect();
8402    let (kept, mark) = if chars.len() <= max {
8403        (line.to_owned(), "")
8404    } else if let Some(end) = sentence_end(&chars, max) {
8405        (chars[..end].iter().collect::<String>(), "")
8406    } else {
8407        let room = max.saturating_sub(3);
8408        // Cut at the last space inside the room; when the char just past the
8409        // room is a space the room already ends on a word.
8410        let cut = if chars[room].is_whitespace() {
8411            room
8412        } else {
8413            chars[..room]
8414                .iter()
8415                .rposition(|c| c.is_whitespace())
8416                .unwrap_or(room)
8417        };
8418        let head: String = chars[..cut].iter().collect();
8419        let head = head.trim_end_matches(|c: char| c.is_whitespace() || ",;:-".contains(c));
8420        (head.to_owned(), "...")
8421    };
8422    if kept.is_empty() || !kept.is_ascii() || !kept.chars().any(|c| c.is_ascii_alphabetic()) {
8423        return None;
8424    }
8425    Some(format!("{kept}{mark}"))
8426}
8427
8428/// The char length of the first sentence of `chars` when it ends within `max`
8429/// (the closing `.`/`!`/`?` dropped), skipping very short stubs and common
8430/// abbreviations so `e.g. foo` does not end a title early.
8431fn sentence_end(chars: &[char], max: usize) -> Option<usize> {
8432    const MIN: usize = 20;
8433    for i in MIN..max.min(chars.len()) {
8434        if !matches!(chars[i], '.' | '!' | '?') {
8435            continue;
8436        }
8437        let Some(&next) = chars.get(i + 1) else {
8438            continue;
8439        };
8440        if !next.is_whitespace() {
8441            continue;
8442        }
8443        let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
8444        if after.is_some_and(|c| c.is_ascii_lowercase()) {
8445            continue;
8446        }
8447        let word: String = chars[..i]
8448            .iter()
8449            .rev()
8450            .take_while(|c| !c.is_whitespace())
8451            .collect::<Vec<_>>()
8452            .into_iter()
8453            .rev()
8454            .collect();
8455        let word = word.to_ascii_lowercase();
8456        if matches!(word.as_str(), "e.g" | "i.e" | "etc" | "vs" | "cf") {
8457            continue;
8458        }
8459        let end = chars[..i]
8460            .iter()
8461            .rposition(|c| !c.is_whitespace())
8462            .map_or(i, |p| p + 1);
8463        return Some(end);
8464    }
8465    None
8466}
8467
8468/// What `merge = "pr"` (and the merge commit of the other modes) says about a
8469/// change: a title and a body describing what was *implemented*, not the task
8470/// that asked for it. A task reads as a request; a reader of the merged
8471/// history wants the change.
8472struct PrMessage {
8473    title: String,
8474    body: String,
8475}
8476
8477impl PrMessage {
8478    /// Title, blank line, body. The first line is the squash/merge commit
8479    /// subject (`manual_merge_command` takes it via `lines().next()`), so it
8480    /// has to stay one sensible line.
8481    fn commit_message(&self) -> String {
8482        format!("{}\n\n{}", self.title, self.body)
8483    }
8484}
8485
8486/// The text after a leading `TITLE:` (any case) on `line`.
8487fn title_marker(line: &str) -> Option<&str> {
8488    let line = line.trim();
8489    let head = line.get(..6)?;
8490    head.eq_ignore_ascii_case("title:")
8491        .then(|| line[6..].trim())
8492}
8493
8494/// The implementer's own one-line title: the `TITLE:` line the implement
8495/// prompt asks for at the top of its SUMMARY. Candidate commits are all
8496/// `magi: candidate X (uncommitted work)`, so a commit subject is never a
8497/// source, and a title that says as much is refused here too.
8498fn summary_title(summary: &str) -> Option<String> {
8499    let first = summary.lines().find(|l| !l.trim().is_empty())?;
8500    let raw = title_marker(first)?;
8501    if raw.is_empty() {
8502        return None;
8503    }
8504    let title = queue::title_from(raw, PR_TITLE_MAX);
8505    let lower = title.to_ascii_lowercase();
8506    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8507        return None;
8508    }
8509    Some(title)
8510}
8511
8512/// How `open_review`'s instruction begins; see [`landing_title`].
8513const REVIEW_PROMPT_OPENING: &str = "Review the work already on branch";
8514
8515/// Marker `open_review` gives a candidate that nothing in the roster wrote.
8516const EXISTING_BRANCH: &str = "(existing branch)";
8517
8518/// Does this run review work that already existed, rather than implement a
8519/// task? Runs recorded before `reviewed_commits` existed carry only the
8520/// candidate marker.
8521fn is_review_run(state: &RunState) -> bool {
8522    state.reviewed_commits.is_some() || state.candidates.iter().any(|c| c.agent == EXISTING_BRANCH)
8523}
8524
8525/// The title of a review-only run: the subject of the oldest commit under
8526/// review. Later commits are usually fixups, and `instruction` is the review
8527/// prompt, which says nothing about the change. GitHub text is English, so a
8528/// non-ASCII or blank subject yields `None` and the caller's neutral title.
8529fn review_title(state: &RunState) -> Option<String> {
8530    english_subject(state.reviewed_commits.as_ref()?.first()?)
8531}
8532
8533/// `raw` as a pull request title, or `None` when it is blank, not English
8534/// (GitHub text is), or one of magi's own candidate commit subjects.
8535fn english_subject(raw: &str) -> Option<String> {
8536    let raw = raw.trim();
8537    if raw.is_empty() || !raw.is_ascii() || !raw.chars().any(|c| c.is_ascii_alphabetic()) {
8538        return None;
8539    }
8540    let title = queue::title_from(raw, PR_TITLE_MAX);
8541    let lower = title.to_ascii_lowercase();
8542    if lower.starts_with("magi:") || lower.contains("(uncommitted work)") {
8543        return None;
8544    }
8545    Some(title)
8546}
8547
8548/// What a review-only run's branch says about itself, read at the moment the
8549/// pull request is opened.
8550#[derive(Debug, Clone, PartialEq, Eq)]
8551struct BranchFacts {
8552    /// `(subject, body)` of each commit, oldest first.
8553    commits: Vec<(String, String)>,
8554    /// Trimmed `git diff --stat`.
8555    stat: String,
8556}
8557
8558/// Longest diff stat shown: this many file lines plus the summary line.
8559const STAT_FILE_LINES: usize = 25;
8560/// Cap on the commit list, well inside GitHub's 65536-character body limit.
8561const COMMITS_MAX_CHARS: usize = 20_000;
8562
8563/// Read the commits and diff stat of `base..branch`. `None` when git cannot
8564/// say or finds nothing, so the caller falls back to what the run recorded.
8565async fn branch_facts(repo: &Path, base: &str, branch: &str) -> Option<BranchFacts> {
8566    let commits = git::commit_log(repo, base, branch).await.ok()?;
8567    if commits.is_empty() {
8568        return None;
8569    }
8570    let stat = git::diff_stat(repo, base, branch).await.unwrap_or_default();
8571    let lines: Vec<&str> = stat.lines().collect();
8572    let stat = if lines.len() > STAT_FILE_LINES + 1 {
8573        let omitted = lines.len() - 1 - STAT_FILE_LINES;
8574        let more = format!(" ... {omitted} more file(s)");
8575        let mut kept: Vec<&str> = lines[..STAT_FILE_LINES].to_vec();
8576        kept.push(&more);
8577        kept.push(lines[lines.len() - 1]);
8578        kept.join("\n")
8579    } else {
8580        lines.join("\n")
8581    };
8582    Some(BranchFacts { commits, stat })
8583}
8584
8585/// Defang what would break the surrounding markdown: a closing `</details>`
8586/// and a code fence.
8587fn markdown_safe(text: &str) -> String {
8588    text.replace("</details>", "&lt;/details&gt;")
8589        .replace("\x60\x60\x60", "~~~")
8590}
8591
8592fn neutral_title(state: &RunState, winner: char) -> String {
8593    format!(
8594        "chore: land candidate {} of run {}",
8595        winner.to_ascii_uppercase(),
8596        state.id
8597    )
8598}
8599
8600/// The pull request title to hand to `land::merge_subject`. A review-only run
8601/// opened by an earlier build titled its pull request with the review prompt;
8602/// that title is dropped (empty, so the fallback applies) rather than landed.
8603/// Any other title, including an operator's rename, passes through untouched,
8604/// and so does every title of a run that implements a task.
8605pub fn landing_title<'a>(state: &RunState, pr_title: &'a str) -> &'a str {
8606    if is_review_run(state) && pr_title.trim_start().starts_with(REVIEW_PROMPT_OPENING) {
8607        ""
8608    } else {
8609        pr_title
8610    }
8611}
8612
8613/// What the squash subject falls back to when the pull request title is empty
8614/// or candidate-shaped: for a review-only run the derived title, never the
8615/// review prompt held in `instruction`.
8616pub fn landing_subject_source(state: &RunState) -> String {
8617    if is_review_run(state) {
8618        let winner = state.candidates.first().map_or('A', |c| c.label);
8619        return review_title(state).unwrap_or_else(|| neutral_title(state, winner));
8620    }
8621    state.instruction.clone()
8622}
8623
8624/// `summary` without its `TITLE:` line, which the pull request title already
8625/// carries.
8626fn summary_without_title(summary: &str) -> String {
8627    let mut lines = summary.trim().lines().peekable();
8628    if lines.peek().is_some_and(|l| title_marker(l).is_some()) {
8629        lines.next();
8630    }
8631    lines.collect::<Vec<_>>().join("\n").trim().to_owned()
8632}
8633
8634/// The pull request title and body for the winning candidate.
8635///
8636/// Title: the implementer's `TITLE:` line ([`summary_title`]), falling back to
8637/// the task's own opening line via [`queue::title_from`] when there is none.
8638/// `state.instruction` can open with blank lines (`task_text` only rejects a
8639/// body that is blank *entirely*), which `title_from` skips.
8640///
8641/// Body: the implementer's summary and the fixer's notes, then — when the
8642/// winning review round was not clean — the findings still open and whatever
8643/// the fixer declined, so `merge = "pr"` hands the reader the same material
8644/// `magi show` does. The task follows inside a collapsed block, and the
8645/// footer repeats the run and candidate as plain tags for a reader holding
8646/// only the merged commit or the PR body.
8647#[cfg(test)]
8648fn pr_message(state: &RunState, winner: char) -> PrMessage {
8649    pr_message_with(state, winner, None)
8650}
8651
8652/// [`pr_message`] with what the branch of a review-only run says about itself.
8653/// `facts` is ignored for a run that implements a task.
8654#[cfg(test)]
8655fn pr_message_with(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8656    let raw = pr_message_raw(state, winner, facts);
8657    let id = crate::scrub::Identity::current();
8658    PrMessage {
8659        title: crate::scrub::scrub(&raw.title, &id),
8660        body: crate::scrub::scrub(&raw.body, &id),
8661    }
8662}
8663
8664fn pr_message_raw(state: &RunState, winner: char, facts: Option<&BranchFacts>) -> PrMessage {
8665    let summary = state
8666        .candidates
8667        .iter()
8668        .find(|c| c.label == winner)
8669        .map(|c| c.summary.as_str())
8670        .unwrap_or_default();
8671    // The fallback is the operator's own words and may not be English; GitHub
8672    // text always is, so a non-English task gets a neutral title instead.
8673    let review = is_review_run(state);
8674    let title = if review {
8675        facts
8676            .and_then(|f| english_subject(&f.commits.first()?.0))
8677            .or_else(|| review_title(state))
8678            .or_else(|| {
8679                state
8680                    .candidates
8681                    .iter()
8682                    .find(|c| c.label == winner)
8683                    .filter(|c| !c.branch.starts_with("magi/"))
8684                    .and_then(|c| english_subject(&c.branch))
8685            })
8686            .unwrap_or_else(|| neutral_title(state, winner))
8687    } else {
8688        summary_title(summary).unwrap_or_else(|| {
8689            english_title(&state.instruction, PR_TITLE_MAX)
8690                .unwrap_or_else(|| neutral_title(state, winner))
8691        })
8692    };
8693
8694    let mut body = String::new();
8695    let what = summary_without_title(summary);
8696    if !what.is_empty() {
8697        body.push_str("## Summary\n\n");
8698        body.push_str(&what);
8699        body.push_str("\n\n");
8700    }
8701
8702    // The last round is usually a clean verification pass with no fix of its
8703    // own, so every round's notes are read, not just the final one's.
8704    let notes: Vec<(usize, &str)> = state
8705        .reviews
8706        .iter()
8707        .filter_map(|r| {
8708            let n = r.fix.as_ref()?.notes.trim();
8709            (!n.is_empty()).then_some((r.round, n))
8710        })
8711        .collect();
8712    if !notes.is_empty() {
8713        body.push_str("## Review fixes\n\n");
8714        if let [(_, only)] = notes.as_slice() {
8715            body.push_str(only);
8716            body.push_str("\n\n");
8717        } else {
8718            for (round, n) in &notes {
8719                body.push_str(&format!("### Round {round}\n\n{n}\n\n"));
8720            }
8721        }
8722    }
8723    let fix = state.reviews.iter().rev().find_map(|r| r.fix.as_ref());
8724
8725    let open = state.open_findings();
8726    if !open.is_empty() {
8727        body.push_str("## Open review findings\n\n");
8728        for f in &open {
8729            body.push_str(&format!("- `{}` [{:?}] {}\n", f.id, f.severity, f.title));
8730        }
8731        body.push('\n');
8732    }
8733
8734    if let Some(fix) = fix
8735        && !fix.rejected.is_empty()
8736    {
8737        body.push_str("## Declined by the fixer\n\n");
8738        for r in &fix.rejected {
8739            body.push_str(&format!("- `{}`: {}\n", r.id, r.why));
8740        }
8741        body.push('\n');
8742    }
8743
8744    if review {
8745        // The review prompt is not the task; list what the branch carries.
8746        body.push_str("## Commits under review\n\n");
8747        if let Some(facts) = facts {
8748            let mut left = COMMITS_MAX_CHARS;
8749            for (i, (subject, text)) in facts.commits.iter().enumerate() {
8750                let mut entry = format!("- {}\n", markdown_safe(subject));
8751                for l in markdown_safe(text).lines() {
8752                    entry.push_str(format!("  {l}\n").trim_end_matches(' '));
8753                }
8754                if left == 0 {
8755                    body.push_str(&format!(
8756                        "- ... {} more commit(s)\n",
8757                        facts.commits.len() - i
8758                    ));
8759                    break;
8760                }
8761                if entry.len() > left {
8762                    // Even the first commit is cut: one huge body must not
8763                    // push the whole description past GitHub's limit.
8764                    let mut end = left;
8765                    while !entry.is_char_boundary(end) {
8766                        end -= 1;
8767                    }
8768                    entry.truncate(end);
8769                    entry.push_str("\n  ... (truncated)\n");
8770                    left = 0;
8771                } else {
8772                    left -= entry.len();
8773                }
8774                body.push_str(&entry);
8775            }
8776            if !facts.stat.trim().is_empty() {
8777                body.push_str(&format!(
8778                    "\n## Diff stat\n\n```\n{}\n```\n",
8779                    markdown_safe(facts.stat.trim())
8780                ));
8781            }
8782        } else {
8783            match &state.reviewed_commits {
8784                Some(subjects) => {
8785                    for s in subjects {
8786                        body.push_str(&format!("- {}\n", s.trim()));
8787                    }
8788                }
8789                None => {
8790                    // An older run kept only the prompt, with the commit list
8791                    // after its first paragraph.
8792                    let rest = state.instruction.split_once("\n\n").map_or("", |(_, r)| r);
8793                    body.push_str(rest.trim());
8794                    body.push('\n');
8795                }
8796            }
8797        }
8798    } else {
8799        let task = state.instruction.trim();
8800        let task = if task.is_empty() {
8801            "(empty task)"
8802        } else {
8803            task
8804        };
8805        body.push_str(&format!(
8806            "<details>\n<summary>Original task</summary>\n\n{}\n\n</details>\n",
8807            task.replace("</details>", "&lt;/details&gt;")
8808        ));
8809    }
8810
8811    body.push_str(&format!(
8812        "\n---\nmagi:run/{} magi:candidate-{}\n",
8813        state.id,
8814        winner.to_ascii_lowercase()
8815    ));
8816
8817    PrMessage { title, body }
8818}
8819
8820/// The task with what the repository says about the existing work it names
8821/// appended, so an implementer knows what it started from and what it must
8822/// not redo. Unchanged when the task names nothing.
8823fn seeded_instruction(state: &RunState) -> String {
8824    match refs::describe(&state.seeds) {
8825        Some(facts) => format!(
8826            "{}\n\n# Existing work the task refers to\n\n{facts}\n\n\
8827             Candidates start from the unmerged branch named above, when there \
8828             is one, and carry any unmerged commit named by sha as a \
8829             cherry-pick. Check that this is what the task meant before \
8830             building on it.",
8831            state.instruction
8832        ),
8833        None => state.instruction.clone(),
8834    }
8835}
8836
8837/// Does the winner have no commits ahead of the base it would land on?
8838/// Any failure to find out reads as "not empty": the merge then behaves as it
8839/// always did rather than refusing on a guess.
8840async fn merge_is_empty(repo: &Path, state: &RunState, branch: &str, mode: MergeMode) -> bool {
8841    let base = &state.base_branch;
8842    let mut against = base.clone();
8843    if mode == MergeMode::Pr {
8844        // A pull request lands on the remote's base, never the local branch
8845        // of the same name: if that cannot be read, "not empty" is the safe
8846        // answer.
8847        let remote = &state.config.merge.remote;
8848        let tracking = format!("{remote}/{base}");
8849        let fetched = git::fetch(repo, remote, base).await;
8850        if fetched.is_ok_and(|o| o.ok()) && git::rev_exists(repo, &tracking).await {
8851            against = tracking;
8852        } else {
8853            return false;
8854        }
8855    }
8856    matches!(git::commits_ahead(repo, &against, branch).await, Ok(0))
8857}
8858
8859/// Why nothing was opened for an empty winner, with what the task's own
8860/// references resolved to.
8861fn empty_candidate_detail(state: &RunState, base: &str) -> String {
8862    let mut detail = format!(
8863        "empty candidate: the winning branch has 0 commits ahead of {base}, so there is \
8864         nothing to open a pull request for"
8865    );
8866    match refs::describe(&state.seeds) {
8867        Some(facts) => detail.push_str(&format!("\nReferences in the task:\n{facts}")),
8868        None => detail.push_str(
8869            "\nThe task names no existing branch or commit; if it means to land work \
8870             that lives elsewhere, name the branch (magi/<run>/<label>) or the sha.",
8871        ),
8872    }
8873    detail
8874}
8875
8876/// What the `Pr` merge does once it knows whether the branch already has an
8877/// open pull request.
8878#[derive(Debug, PartialEq, Eq)]
8879enum PrPlan {
8880    Create,
8881    Adopt { url: String, title: String },
8882    Stop(String),
8883}
8884
8885/// Pure decision behind the `Pr` merge: none -> create, one -> adopt, many or
8886/// a failed lookup -> stop with the real reason. Never guesses.
8887fn pr_merge_plan(found: Result<land::OpenPr>) -> PrPlan {
8888    match found {
8889        Ok(land::OpenPr::None) => PrPlan::Create,
8890        Ok(land::OpenPr::One { url, title }) => PrPlan::Adopt { url, title },
8891        Ok(land::OpenPr::Many(urls)) => PrPlan::Stop(format!(
8892            "several open pull requests exist for this branch, not picking one: {}",
8893            urls.join(" ")
8894        )),
8895        Err(e) => PrPlan::Stop(format!("could not look up open pull requests: {e:#}")),
8896    }
8897}
8898
8899/// `gh pr create`, returning the PR url.
8900async fn gh_pr_create(
8901    cwd: &Path,
8902    base: &str,
8903    head: &str,
8904    title: &str,
8905    body: &str,
8906) -> Result<String> {
8907    let out = tokio::process::Command::new("gh")
8908        .args([
8909            "pr", "create", "--base", base, "--head", head, "--title", title, "--body", body,
8910        ])
8911        .current_dir(cwd)
8912        .quiet()
8913        .stdin(std::process::Stdio::null())
8914        .output()
8915        .await
8916        .context("spawn gh")?;
8917    if out.status.success() {
8918        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
8919    } else {
8920        bail!("{}", String::from_utf8_lossy(&out.stderr).trim().to_owned())
8921    }
8922}
8923
8924/// Tear a run's worktrees and branches down.
8925///
8926/// `home` is where the updated `run.json` is saved (via
8927/// [`RunState::save_under`]), never the process-global [`crate::run::home`]:
8928/// a housekeeping pass already has its own honest `home` handed to it, and
8929/// falling through to the global here would write back through whichever
8930/// directory some other process or test pinned into that `OnceLock` first,
8931/// not the one the caller actually resolved its `runs` and `state` from.
8932pub async fn fold_run(state: &mut RunState, drop_winner: bool, home: &Path) -> Result<Vec<String>> {
8933    let repo = state.repo.clone();
8934    let root = state.worktree_root();
8935    let winner = state.tally.as_ref().map(|t| t.winner);
8936    let mut removed = Vec::new();
8937
8938    for i in 0..state.candidates.len() {
8939        let c = state.candidates[i].clone();
8940        let is_winner = Some(c.label) == winner;
8941        if is_winner && !drop_winner {
8942            continue;
8943        }
8944        if c.worktree.exists() {
8945            git::worktree_remove(&repo, &c.worktree).await.ok();
8946            removed.push(c.worktree.to_string_lossy().into_owned());
8947        }
8948        // A branch handed to a later run (and its pull request) is not this
8949        // run's to delete.
8950        let handed_over = state.released_branches.contains(&c.branch);
8951        if !handed_over && git::branch_exists(&repo, &c.branch).await.unwrap_or(false) {
8952            git::branch_delete(&repo, &c.branch).await.ok();
8953            removed.push(c.branch.clone());
8954        }
8955        state.candidates[i].folded = true;
8956    }
8957
8958    for name in std::fs::read_dir(&root).into_iter().flatten().flatten() {
8959        let path = name.path();
8960        let keep = !drop_winner
8961            && winner.is_some_and(|w| {
8962                path.file_name()
8963                    .is_some_and(|n| n == format!("cand-{w}").as_str())
8964            });
8965        if keep {
8966            continue;
8967        }
8968        git::worktree_remove(&repo, &path).await.ok();
8969        removed.push(path.to_string_lossy().into_owned());
8970    }
8971
8972    // `root` (`wt/<...>/<short>/`) held nothing but this run's candidate and
8973    // judge worktrees, so once the loop above has cleared all of them out,
8974    // the parent is a bare directory nobody else was ever going to remove -
8975    // git only ever managed what was inside it. Left alone, one of these
8976    // accumulates per fully-folded run; the operator's own machine had 74.
8977    // `remove_if_empty` re-checks rather than assuming: a run whose winner
8978    // was kept (`!drop_winner`) leaves its directory behind on purpose, and
8979    // so does anything a run never claimed that happens to share the bay.
8980    remove_if_empty(&root);
8981
8982    if state.enabled_worktree_config && drop_winner {
8983        // A release, not a raw disable: some sibling run in this repository
8984        // may still hold its own reference (see `git::acquire_worktree_config`),
8985        // and only the last release actually turns the setting back off.
8986        git::release_worktree_config(&repo).await.ok();
8987        state.enabled_worktree_config = false;
8988    }
8989    state.save_under(home)?;
8990    Ok(removed)
8991}
8992
8993/// Remove `dir` if it exists and has nothing in it.
8994///
8995/// Best-effort and silent by design: a directory that is not empty (a run
8996/// whose winner is still parked there, a stray file some other process left)
8997/// is exactly the case this must refuse, and a directory that is already gone
8998/// is not a failure worth reporting either. `std::fs::remove_dir` itself
8999/// already refuses a non-empty directory, so the emptiness check below is
9000/// belt, not suspenders - it is what keeps this from ever attempting the
9001/// removal in the case that matters, rather than trusting `remove_dir`'s
9002/// error path to have no side effects if it ever changed.
9003fn remove_if_empty(dir: &Path) {
9004    if dir.is_dir() && std::fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_none()) {
9005        std::fs::remove_dir(dir).ok();
9006    }
9007}
9008
9009/// Severity of the worst open finding in the last review round, for reporting.
9010pub fn worst_open(state: &RunState) -> Option<Severity> {
9011    state
9012        .reviews
9013        .last()?
9014        .reviews
9015        .iter()
9016        .flat_map(|r| r.findings.iter())
9017        .map(|f| f.severity)
9018        .max()
9019}
9020
9021/// `[worktree] setup` hides its products per worktree, which needs
9022/// `extensions.worktreeConfig`. Taken once per run and released with the run's
9023/// worktrees, like the hook's; every place that runs setup calls this first.
9024async fn ensure_setup_config(state: &mut RunState, repo: &Path) -> Result<()> {
9025    if !state.config.worktree.setup.is_empty() && !state.enabled_worktree_config {
9026        git::acquire_worktree_config(repo).await?;
9027        state.enabled_worktree_config = true;
9028    }
9029    Ok(())
9030}
9031
9032#[cfg(test)]
9033mod tests {
9034    #[test]
9035    fn should_retitle_only_replaces_magi_shaped_or_leaked_titles() {
9036        let leaked = vec!["chore(deps): update a crate".to_owned()];
9037        let own = "fix(daemon): apply a chosen action";
9038        assert!(should_retitle("", own, &leaked));
9039        assert!(should_retitle(
9040            &format!("{REVIEW_PROMPT_OPENING} `x`"),
9041            own,
9042            &leaked
9043        ));
9044        assert!(should_retitle(
9045            "chore: land candidate A of run 1",
9046            own,
9047            &leaked
9048        ));
9049        assert!(should_retitle(
9050            "magi: candidate A (uncommitted work)",
9051            own,
9052            &leaked
9053        ));
9054        assert!(should_retitle("chore(deps): update a crate", own, &leaked));
9055        assert!(!should_retitle("feat: renamed by hand", own, &leaked));
9056        assert!(!should_retitle("", "chore(deps): update a crate", &leaked));
9057    }
9058
9059    #[test]
9060    fn pr_merge_plan_creates_adopts_or_stops() {
9061        assert_eq!(pr_merge_plan(Ok(land::OpenPr::None)), PrPlan::Create);
9062        assert_eq!(
9063            pr_merge_plan(Ok(land::OpenPr::One {
9064                url: "u".into(),
9065                title: "t".into()
9066            })),
9067            PrPlan::Adopt {
9068                url: "u".into(),
9069                title: "t".into()
9070            }
9071        );
9072        let PrPlan::Stop(many) =
9073            pr_merge_plan(Ok(land::OpenPr::Many(vec!["a".into(), "b".into()])))
9074        else {
9075            panic!("many must stop");
9076        };
9077        assert!(many.contains('a') && many.contains('b'));
9078        let PrPlan::Stop(err) = pr_merge_plan(Err(anyhow::anyhow!("bad token"))) else {
9079            panic!("a failed lookup must stop");
9080        };
9081        assert!(err.contains("bad token"));
9082    }
9083
9084    use super::*;
9085    use crate::run::GateStatus;
9086    use std::collections::BTreeMap;
9087    use std::time::Duration;
9088
9089    fn conductor() -> AgentSpec {
9090        AgentSpec {
9091            id: "conductor".to_owned(),
9092            kind: crate::config::AgentKind::Command,
9093            model: None,
9094            command: vec!["true".to_owned()],
9095            extra_args: Vec::new(),
9096            env: BTreeMap::new(),
9097            prompt_delivery: None,
9098        }
9099    }
9100
9101    fn spec(id: &str) -> AgentSpec {
9102        AgentSpec {
9103            id: id.to_owned(),
9104            kind: crate::config::AgentKind::Command,
9105            model: None,
9106            command: vec!["true".to_owned()],
9107            extra_args: Vec::new(),
9108            env: BTreeMap::new(),
9109            prompt_delivery: None,
9110        }
9111    }
9112
9113    fn ids(xs: &[&str]) -> BTreeSet<String> {
9114        xs.iter().map(|s| (*s).to_owned()).collect()
9115    }
9116
9117    #[test]
9118    fn pick_successor_skips_an_agent_another_seat_holds() {
9119        // Seats a and b of roster [a, b, c]; a fails, b holds the other seat.
9120        let roster = [spec("a"), spec("b"), spec("c")];
9121        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9122        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9123    }
9124
9125    #[test]
9126    fn pick_successor_respects_the_occupant_after_an_earlier_handover() {
9127        // The other seat started on c but was handed to d; c is free again.
9128        let roster = [spec("a"), spec("b"), spec("c"), spec("d")];
9129        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9130        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9131        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b", "c"]));
9132        assert_eq!(next.map(|s| s.id.as_str()), Some("d"));
9133    }
9134
9135    #[test]
9136    fn pick_successor_falls_back_to_a_duplicate_when_no_distinct_agent_remains() {
9137        let roster = [spec("a"), spec("b")];
9138        let next = pick_successor(&roster, 0, &ids(&["a"]), None, &ids(&["b"]));
9139        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9140        let carried = ids(&["b"]);
9141        let next = pick_successor(&roster, 0, &ids(&["a"]), Some(&carried), &ids(&["b"]));
9142        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9143    }
9144
9145    #[test]
9146    fn pick_successor_returns_none_without_any_untried_successor() {
9147        let roster = [spec("a"), spec("b")];
9148        assert!(pick_successor(&roster, 1, &ids(&["b"]), None, &ids(&["a"])).is_none());
9149        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), None, &ids(&[])).is_none());
9150        let carried = ids(&["a"]);
9151        assert!(pick_successor(&roster, 0, &ids(&["a", "b"]), Some(&carried), &ids(&[])).is_none());
9152    }
9153
9154    #[test]
9155    fn pick_successor_rescue_avoids_another_seats_occupant() {
9156        // b is a carried failure and free; a is held by the other seat.
9157        let roster = [spec("a"), spec("b"), spec("c")];
9158        let carried = ids(&["b", "c"]);
9159        let next = pick_successor(&roster, 2, &ids(&["c"]), Some(&carried), &ids(&["a"]));
9160        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9161    }
9162
9163    #[test]
9164    fn next_for_seat_prefers_an_agent_that_has_not_failed() {
9165        let roster = [spec("a"), spec("b"), spec("c")];
9166        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["b"]));
9167        assert_eq!(next.map(|s| s.id.as_str()), Some("c"));
9168    }
9169
9170    #[test]
9171    fn next_for_seat_rescues_a_failed_agent_only_when_nothing_else_is_left() {
9172        let roster = [spec("a"), spec("b"), spec("c")];
9173        let failed = ids(&["a", "b", "c"]);
9174        // Rescue looks at the whole roster, once per id, then runs out.
9175        let mut tried = ids(&["b"]);
9176        let first = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9177        assert_eq!(first.id, "a");
9178        tried.insert(first.id.clone());
9179        let second = next_for_seat(&roster, 1, &tried, &failed).expect("rescue");
9180        assert_eq!(second.id, "c");
9181        tried.insert(second.id.clone());
9182        assert!(next_for_seat(&roster, 1, &tried, &failed).is_none());
9183    }
9184
9185    #[test]
9186    fn next_for_seat_ignores_failed_ids_no_longer_on_the_roster() {
9187        let roster = [spec("a"), spec("b")];
9188        let next = next_for_seat(&roster, 0, &ids(&["a"]), &ids(&["gone"]));
9189        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9190    }
9191
9192    #[test]
9193    fn pick_start_spec_starts_on_the_last_answerer_when_still_eligible() {
9194        let roster = [spec("a"), spec("b"), spec("c")];
9195        let h = SeatHistory {
9196            failed: ids(&["a"]),
9197            last_ok: Some("b".to_owned()),
9198            last_fail: None,
9199        };
9200        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&h)).id, "b");
9201        // A last answerer that left the roster, or later failed, is ignored.
9202        let gone = SeatHistory {
9203            last_ok: Some("zzz".to_owned()),
9204            ..h.clone()
9205        };
9206        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&gone)).id, "b");
9207        let failed = SeatHistory {
9208            failed: ids(&["a", "b"]),
9209            last_ok: Some("b".to_owned()),
9210            last_fail: None,
9211        };
9212        assert_eq!(pick_start_spec(&roster, spec("a"), Some(&failed)).id, "c");
9213    }
9214
9215    #[test]
9216    fn handover_seat_mints_a_session_id_distinct_from_the_previous_agents() {
9217        let first = SeatState::new("review-1", "alpha", 7);
9218        let next = handover_seat("review-1", "gamma", 7);
9219        assert_ne!(first.claude_session, next.claude_session);
9220    }
9221
9222    #[test]
9223    fn re_handing_a_seat_to_the_same_agent_mints_a_new_session_id() {
9224        let mut state = state_with_summary("x", "y");
9225        let a = handover_seat("review-1", "beta", state.next_seat_seed());
9226        let b = handover_seat("review-1", "beta", state.next_seat_seed());
9227        assert_ne!(a.claude_session, b.claude_session);
9228    }
9229
9230    #[test]
9231    fn pick_start_spec_falls_back_to_the_spec_when_the_whole_roster_failed() {
9232        let roster = [spec("a"), spec("b")];
9233        let h = SeatHistory {
9234            failed: ids(&["a", "b"]),
9235            ..SeatHistory::default()
9236        };
9237        assert_eq!(pick_start_spec(&roster, spec("b"), Some(&h)).id, "b");
9238        assert_eq!(pick_start_spec(&roster, spec("b"), None).id, "b");
9239        assert_eq!(pick_start_spec(&[], spec("b"), Some(&h)).id, "b");
9240    }
9241
9242    // `next_untried_in_roster` is the property `resume_seat_handovers`'s own
9243    // fallback loop depends on to terminate: it must walk forward from the
9244    // seat's own position, never restart at the front of the roster, and it
9245    // must never hand back an id already tried, however many times that id
9246    // happens to appear.
9247
9248    #[test]
9249    fn failure_signature_ignores_numbers_and_paths() {
9250        assert_eq!(
9251            failure_signature("exited with Some(2) and no usable output"),
9252            failure_signature("exited with Some(137) and no usable output")
9253        );
9254        assert_eq!(
9255            failure_signature("cannot open /tmp/a/b.txt: denied\nsecond line"),
9256            failure_signature("cannot open /var/x.txt: denied")
9257        );
9258        assert_ne!(failure_signature("boom"), failure_signature("bang"));
9259    }
9260
9261    #[test]
9262    fn quota_and_timeout_always_hand_over_other_failures_stop_on_a_repeat() {
9263        let other = FailClass::Other("x".into());
9264        assert!(should_hand_over(None, &FailClass::Quota));
9265        assert!(should_hand_over(Some(&other), &FailClass::Quota));
9266        assert!(should_hand_over(
9267            Some(&FailClass::Timeout),
9268            &FailClass::Timeout
9269        ));
9270        assert!(should_hand_over(None, &other));
9271        assert!(!should_hand_over(Some(&other), &other));
9272        assert!(should_hand_over(
9273            Some(&other),
9274            &FailClass::Other("y".into())
9275        ));
9276        // A quota or timeout in between ends the run of identical failures.
9277        assert!(should_hand_over(Some(&FailClass::Timeout), &other));
9278        assert!(should_hand_over(Some(&FailClass::Quota), &other));
9279    }
9280
9281    #[test]
9282    fn a_handover_seat_never_reuses_the_previous_agents_session_id() {
9283        let a = SeatState::new("judge-1", "alpha", 7);
9284        let b = handover_seat("judge-1", "beta", 7);
9285        assert_ne!(a.claude_session, b.claude_session);
9286        assert_eq!(b.turns, 0);
9287    }
9288
9289    #[test]
9290    fn a_timeout_is_classified_apart_from_other_failures() {
9291        assert_eq!(
9292            FailClass::of(&AgentOutcome::Failed(TIMED_OUT.to_owned())),
9293            Some(FailClass::Timeout)
9294        );
9295        assert!(matches!(
9296            FailClass::of(&AgentOutcome::Failed("boom".to_owned())),
9297            Some(FailClass::Other(_))
9298        ));
9299    }
9300
9301    #[test]
9302    fn next_untried_in_roster_walks_forward_from_the_seats_own_position() {
9303        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9304        let tried = BTreeSet::from(["beta".to_owned()]);
9305        // beta sits at index 1; the next candidate is gamma, never alpha —
9306        // which is very likely a different candidate slot's own agent.
9307        let next = next_untried_in_roster(&roster, 1, &tried);
9308        assert_eq!(next.map(|s| s.id.as_str()), Some("gamma"));
9309    }
9310
9311    #[test]
9312    fn next_untried_in_roster_does_not_wrap_back_past_its_own_start() {
9313        let roster = vec![spec("alpha"), spec("beta")];
9314        let tried = BTreeSet::from(["beta".to_owned()]);
9315        // beta is the roster's last entry: nothing follows it, and alpha —
9316        // earlier in the roster, almost certainly a different candidate
9317        // slot's own agent — must not be reached by wrapping back to it.
9318        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9319    }
9320
9321    #[test]
9322    fn next_untried_in_roster_stops_once_the_tail_is_exhausted_even_if_earlier_ids_are_untried() {
9323        let roster = vec![spec("alpha"), spec("beta"), spec("gamma")];
9324        let tried = BTreeSet::from(["beta".to_owned(), "gamma".to_owned()]);
9325        // beta (index 1) and gamma (index 2, the only entry after it) have
9326        // both been tried; alpha (index 0) never has, but it comes before
9327        // beta's own position, so there is nothing further for this seat.
9328        assert!(next_untried_in_roster(&roster, 1, &tried).is_none());
9329    }
9330
9331    #[test]
9332    fn next_untried_in_roster_skips_ids_already_tried_even_when_duplicated() {
9333        let roster = vec![spec("a"), spec("a"), spec("b")];
9334        let tried = BTreeSet::from(["a".to_owned()]);
9335        let next = next_untried_in_roster(&roster, 0, &tried);
9336        assert_eq!(next.map(|s| s.id.as_str()), Some("b"));
9337    }
9338
9339    #[test]
9340    fn next_untried_in_roster_returns_none_once_every_id_is_tried() {
9341        let roster = vec![spec("a"), spec("b")];
9342        let tried = BTreeSet::from(["a".to_owned(), "b".to_owned()]);
9343        assert!(next_untried_in_roster(&roster, 0, &tried).is_none());
9344    }
9345
9346    #[test]
9347    fn remove_if_empty_only_ever_takes_a_bare_directory() {
9348        let dir = tempfile::tempdir().unwrap();
9349        let bay = dir.path().join("ffff");
9350
9351        // Not there yet: nothing to do, nothing to panic on.
9352        remove_if_empty(&bay);
9353        assert!(!bay.exists());
9354
9355        // Something still inside - the winner's worktree, or a stray file -
9356        // keeps the directory standing.
9357        std::fs::create_dir_all(bay.join("cand-A")).unwrap();
9358        remove_if_empty(&bay);
9359        assert!(bay.exists(), "non-empty directory must survive");
9360
9361        // Once the last entry is gone, so is the directory itself.
9362        std::fs::remove_dir(bay.join("cand-A")).unwrap();
9363        remove_if_empty(&bay);
9364        assert!(!bay.exists(), "an empty bay is a leftover, not a record");
9365    }
9366
9367    // `round_is_clean` is the exact decision this task fixed: a round with a
9368    // seat that never answered must not read the same as a round every seat
9369    // actually reviewed. These are deterministic and process-free by design —
9370    // the equivalent end-to-end check (a real reviewer timing out under a
9371    // live graph run) is a genuine race against wall-clock contention, and a
9372    // spawn slow enough to blow even a generous budget under a loaded test
9373    // run must not turn this specific regression check flaky.
9374
9375    #[test]
9376    fn a_full_panel_that_found_nothing_is_clean() {
9377        assert!(round_is_clean(
9378            0,
9379            true,
9380            2,
9381            2,
9382            0,
9383            IncompleteReviewPolicy::Block
9384        ));
9385    }
9386
9387    #[test]
9388    fn a_missing_seat_is_never_clean_under_the_default_policy() {
9389        assert!(!round_is_clean(
9390            0,
9391            true,
9392            1,
9393            2,
9394            0,
9395            IncompleteReviewPolicy::Block
9396        ));
9397    }
9398
9399    #[test]
9400    fn warn_policy_still_refuses_a_missing_seat_with_open_findings() {
9401        assert!(!round_is_clean(
9402            1,
9403            true,
9404            1,
9405            2,
9406            0,
9407            IncompleteReviewPolicy::Warn
9408        ));
9409    }
9410
9411    #[test]
9412    fn warn_policy_gates_a_missing_seat_once_what_answered_is_clean() {
9413        assert!(round_is_clean(
9414            0,
9415            true,
9416            1,
9417            2,
9418            0,
9419            IncompleteReviewPolicy::Warn
9420        ));
9421    }
9422
9423    #[test]
9424    fn a_full_panel_with_an_open_finding_is_not_clean() {
9425        assert!(!round_is_clean(
9426            1,
9427            true,
9428            2,
9429            2,
9430            0,
9431            IncompleteReviewPolicy::Block
9432        ));
9433    }
9434
9435    #[test]
9436    fn a_full_panel_with_a_red_e2e_is_not_clean() {
9437        assert!(!round_is_clean(
9438            0,
9439            false,
9440            2,
9441            2,
9442            0,
9443            IncompleteReviewPolicy::Block
9444        ));
9445    }
9446
9447    // The stall this task closes: under the default `block` policy, a seat
9448    // missing only because it was rate limited must not force a wait for a
9449    // session limit that will not lift by the next round. `round_is_clean`
9450    // is where that quorum carve-out lives; the review loop around it never
9451    // changes what a reviewer's vote or a finding's severity means.
9452
9453    #[test]
9454    fn a_seat_missing_only_to_its_own_quota_is_clean_under_the_default_policy() {
9455        // 1 of 2 answered, and the one missing was quota'd — the exact
9456        // "review-2 rate limited (quota)" shape from the field report.
9457        assert!(round_is_clean(
9458            0,
9459            true,
9460            1,
9461            2,
9462            1,
9463            IncompleteReviewPolicy::Block
9464        ));
9465    }
9466
9467    #[test]
9468    fn a_seat_missing_for_a_reason_other_than_quota_still_waits() {
9469        // 1 of 2 answered, but the miss was a crash/timeout/parse failure,
9470        // not a quota loss (`quota_missing` stays 0) — worth another try.
9471        assert!(!round_is_clean(
9472            0,
9473            true,
9474            1,
9475            2,
9476            0,
9477            IncompleteReviewPolicy::Block
9478        ));
9479    }
9480
9481    #[test]
9482    fn a_quota_loss_does_not_excuse_an_open_finding_or_a_red_e2e() {
9483        assert!(!round_is_clean(
9484            1,
9485            true,
9486            1,
9487            2,
9488            1,
9489            IncompleteReviewPolicy::Block
9490        ));
9491        assert!(!round_is_clean(
9492            0,
9493            false,
9494            1,
9495            2,
9496            1,
9497            IncompleteReviewPolicy::Block
9498        ));
9499    }
9500
9501    #[test]
9502    fn a_panel_lost_entirely_to_quota_still_waits_rather_than_deciding_on_nobody() {
9503        // Every seat quota'd, nobody answered: there is no panel to decide
9504        // on, so this must fall through to the existing block-and-retry
9505        // fallback rather than call an unreviewed patch clean.
9506        assert!(!round_is_clean(
9507            0,
9508            true,
9509            0,
9510            2,
9511            2,
9512            IncompleteReviewPolicy::Block
9513        ));
9514    }
9515
9516    fn outcome(code: Option<i32>, resource_blocked: bool) -> CommandOutcome {
9517        CommandOutcome {
9518            command: "test".to_owned(),
9519            code,
9520            output_tail: String::new(),
9521            duration_ms: 0,
9522            resource_blocked,
9523        }
9524    }
9525
9526    #[test]
9527    fn verify_is_inconclusive_only_when_a_resource_blocked_outcome_is_present() {
9528        assert!(!verify_inconclusive(&[outcome(Some(0), false)]));
9529        assert!(
9530            !verify_inconclusive(&[outcome(Some(1), false)]),
9531            "an ordinary failure is still evidence about the patch"
9532        );
9533        assert!(verify_inconclusive(&[outcome(None, true)]));
9534        assert!(
9535            verify_inconclusive(&[outcome(Some(0), false), outcome(None, true)]),
9536            "one inconclusive outcome taints the whole batch"
9537        );
9538        assert!(!verify_inconclusive(&[]));
9539    }
9540
9541    #[tokio::test]
9542    async fn timed_out_pid_waiting_returns_as_soon_as_every_pid_is_confirmed_dead() {
9543        // Alive for the first two checks, then dead - confirms the loop
9544        // actually re-polls rather than deciding once and sleeping out the
9545        // ceiling regardless.
9546        let calls = std::sync::atomic::AtomicUsize::new(0);
9547        let started = Instant::now();
9548        wait_for_pids_with(
9549            &[123],
9550            |_| calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) < 2,
9551            Duration::from_millis(5),
9552            Duration::from_secs(5),
9553        )
9554        .await;
9555        assert!(
9556            calls.load(std::sync::atomic::Ordering::SeqCst) >= 3,
9557            "must keep checking rather than deciding on the first answer"
9558        );
9559        assert!(
9560            started.elapsed() < Duration::from_secs(1),
9561            "must return the moment it is confirmed dead, not wait out the ceiling"
9562        );
9563    }
9564
9565    #[tokio::test]
9566    async fn timed_out_pid_waiting_gives_up_at_its_ceiling_if_never_confirmed_dead() {
9567        let started = Instant::now();
9568        wait_for_pids_with(
9569            &[123],
9570            |_| true, // never reports dead
9571            Duration::from_millis(5),
9572            Duration::from_millis(30),
9573        )
9574        .await;
9575        let elapsed = started.elapsed();
9576        assert!(
9577            elapsed >= Duration::from_millis(30),
9578            "must not give up before its own ceiling: {elapsed:?}"
9579        );
9580        assert!(
9581            elapsed < Duration::from_secs(1),
9582            "must not wait past its own ceiling either: {elapsed:?}"
9583        );
9584    }
9585
9586    #[tokio::test]
9587    async fn timed_out_pid_waiting_is_a_no_op_when_nothing_was_still_running() {
9588        let started = Instant::now();
9589        wait_for_pids_with(
9590            &[],
9591            |_| true,
9592            Duration::from_secs(5),
9593            Duration::from_secs(5),
9594        )
9595        .await;
9596        assert!(
9597            started.elapsed() < Duration::from_millis(200),
9598            "an empty pid list has nothing to confirm"
9599        );
9600    }
9601
9602    // `review_conclusion` is the exact decision the review hand-off task
9603    // fixed: a round budget spent (or a tree that stopped moving) must not
9604    // collapse into `Blocked` regardless of what verification actually
9605    // said. Deterministic and process-free for the same reason the
9606    // `round_is_clean` family above is.
9607    fn review_round(
9608        clean: bool,
9609        blocking: usize,
9610        answered: usize,
9611        expected: usize,
9612        progressed: bool,
9613        e2e_ok: bool,
9614    ) -> ReviewRound {
9615        ReviewRound {
9616            round: 1,
9617            head: "h".to_owned(),
9618            verified_head: None,
9619            verified_at: None,
9620            reviews: Vec::new(),
9621            e2e: vec![CommandOutcome {
9622                command: "test".to_owned(),
9623                code: Some(if e2e_ok { 0 } else { 1 }),
9624                output_tail: String::new(),
9625                duration_ms: 0,
9626                resource_blocked: false,
9627            }],
9628            verify_retried: false,
9629            e2e_deferred: false,
9630            e2e_defer_reason: None,
9631            fix: None,
9632            blocking,
9633            answered,
9634            expected,
9635            clean,
9636            progressed,
9637            vote_split: false,
9638            reconsideration: Vec::new(),
9639            verdict: None,
9640        }
9641    }
9642
9643    #[test]
9644    fn review_conclusion_is_none_when_nothing_has_run() {
9645        assert_eq!(review_conclusion(&[], 3), None);
9646    }
9647
9648    #[test]
9649    fn review_conclusion_is_none_while_rounds_remain() {
9650        let rounds = vec![review_round(false, 1, 2, 2, true, true)];
9651        assert_eq!(review_conclusion(&rounds, 3), None);
9652    }
9653
9654    #[test]
9655    fn review_conclusion_is_gating_once_a_round_is_clean() {
9656        let rounds = vec![review_round(true, 0, 2, 2, false, true)];
9657        assert_eq!(review_conclusion(&rounds, 3), Some(RunStatus::Gating));
9658    }
9659
9660    #[test]
9661    fn review_conclusion_hands_off_when_the_budget_is_spent_and_e2e_is_green() {
9662        let rounds = vec![
9663            review_round(false, 1, 2, 2, true, true),
9664            review_round(false, 1, 2, 2, true, true),
9665        ];
9666        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Gating));
9667    }
9668
9669    #[test]
9670    fn review_conclusion_blocks_when_the_budget_is_spent_and_e2e_is_red() {
9671        let rounds = vec![
9672            review_round(false, 1, 2, 2, true, true),
9673            review_round(false, 1, 2, 2, true, false),
9674        ];
9675        assert_eq!(review_conclusion(&rounds, 2), Some(RunStatus::Blocked));
9676    }
9677
9678    #[test]
9679    fn review_conclusion_stays_none_when_the_budget_is_spent_but_the_last_round_could_not_run() {
9680        // Magi never got a command to run against this round's own head — a
9681        // resource-blocked attempt, not a red one — so this must never
9682        // settle on `Blocked` the way a genuine e2e failure would. `None`
9683        // here is what tells `Runner::review_loop` to retry the check
9684        // itself rather than trust this cheap recomputation with a verdict
9685        // it cannot actually produce.
9686        let mut blocked = review_round(false, 1, 2, 2, true, false);
9687        blocked.e2e[0].resource_blocked = true;
9688        let rounds = vec![review_round(false, 1, 2, 2, true, true), blocked];
9689        assert_eq!(review_conclusion(&rounds, 2), None);
9690    }
9691
9692    #[test]
9693    fn review_conclusion_blocks_an_incomplete_panel_that_raised_nothing_even_with_green_e2e() {
9694        // Missing input, not a verified tree — never a hand-off candidate.
9695        let rounds = vec![review_round(false, 0, 1, 2, false, true)];
9696        assert_eq!(review_conclusion(&rounds, 1), Some(RunStatus::Blocked));
9697    }
9698
9699    #[test]
9700    fn review_conclusion_hands_off_when_the_tree_stagnates_before_the_budget_is_spent() {
9701        let rounds = vec![
9702            review_round(false, 1, 2, 2, false, true),
9703            review_round(false, 1, 2, 2, false, true),
9704        ];
9705        assert_eq!(review_conclusion(&rounds, 10), Some(RunStatus::Gating));
9706    }
9707
9708    fn secs(n: u64) -> Duration {
9709        Duration::from_secs(n)
9710    }
9711
9712    /// A throwaway repo with one commit on `main`, for tests that need `merge`
9713    /// to make real (and, if it runs at all, real*ly fail*) git calls.
9714    fn init_repo(dir: &Path) {
9715        let run = |args: &[&str]| {
9716            let out = std::process::Command::new("git")
9717                .args(args)
9718                .current_dir(dir)
9719                .quiet()
9720                .output()
9721                .expect("spawn git");
9722            assert!(
9723                out.status.success(),
9724                "git {args:?} failed: {}",
9725                String::from_utf8_lossy(&out.stderr)
9726            );
9727        };
9728        run(&["init", "-b", "main"]);
9729        run(&["config", "user.name", "magi test"]);
9730        run(&["config", "user.email", "magi@example.com"]);
9731        std::fs::write(dir.join("README.md"), "# fixture\n").unwrap();
9732        run(&["add", "-A"]);
9733        run(&["commit", "-m", "init"]);
9734    }
9735
9736    // `settle_questions` is what closes the ghost the phone showed: a run's
9737    // seat asked something, the run then ended, and nothing was left to
9738    // abandon the question it left `open`. `HOME` is a process-wide
9739    // `OnceLock` (see `run::set_home`'s doc), so this only wins the race the
9740    // first time it runs in the binary — every test below still reaches the
9741    // same directory whichever call won, and each gets its own run id from
9742    // `RunState::new`, so they never collide there.
9743    fn ask_test_home() {
9744        crate::run::pin_test_home();
9745    }
9746
9747    /// A minimal, git-free `Runner` at a given status — `settle_questions`
9748    /// reads nothing else off it.
9749    fn runner_at(status: RunStatus) -> Runner {
9750        let mut state = RunState::new(
9751            PathBuf::from("/nonexistent/repo"),
9752            "main".to_owned(),
9753            "deadbeef".to_owned(),
9754            "task".to_owned(),
9755            Config::default(),
9756        );
9757        state.status = status;
9758        Runner {
9759            state,
9760            roles: ResolvedRoles {
9761                implementers: Vec::new(),
9762                judges: Vec::new(),
9763                reviewers: Vec::new(),
9764                fixer: None,
9765                conductor: conductor(),
9766                implementer_roster: Vec::new(),
9767                judge_roster: Vec::new(),
9768                reviewer_roster: Vec::new(),
9769            },
9770            sem: Arc::new(Semaphore::new(1)),
9771            pause: Pause::new(),
9772            interrupt: Pause::new(),
9773        }
9774    }
9775
9776    /// `park_here` folding in the reason `Pause::park_because` recorded -
9777    /// this is what lets an operator reading a run's events tell an
9778    /// interrupt-driven park from an ordinary shutdown park.
9779    #[test]
9780    fn park_here_folds_the_interrupt_reason_into_the_park_event() {
9781        crate::run::pin_test_home();
9782        let mut runner = runner_at(RunStatus::Implementing);
9783        let interrupt = Pause::new();
9784        runner.watch_interrupt(interrupt.clone());
9785
9786        interrupt.park_because("task a1b2 asked to run first");
9787
9788        assert!(runner.park_here().expect("park_here"));
9789        assert!(runner.state.parked);
9790        let last = runner.state.events.last().expect("a park event");
9791        assert_eq!(last.node, "park");
9792        assert!(
9793            last.message.contains("task a1b2 asked to run first"),
9794            "expected the interrupt reason in {:?}",
9795            last.message
9796        );
9797    }
9798
9799    /// `watch_interrupt` and `on_pause` are genuinely independent: an ordinary
9800    /// shutdown `Pause` (what `Stop::park` hands every run, shared and never
9801    /// cleared) must not make a *different* run - one only watching its own,
9802    /// unshared interrupt `Pause` - see itself as parked. If a future change
9803    /// ever collapsed these back into one handle, the interrupt scheduler
9804    /// would park every run for the rest of the daemon's life, not just the
9805    /// one it meant to interrupt.
9806    #[test]
9807    fn the_stop_level_pause_and_a_runs_interrupt_pause_do_not_leak_into_each_other() {
9808        crate::run::pin_test_home();
9809        let mut runner = runner_at(RunStatus::Implementing);
9810        let shutdown = Pause::new();
9811        runner.on_pause(shutdown.clone());
9812        let interrupt = Pause::new();
9813        runner.watch_interrupt(interrupt.clone());
9814
9815        // Nobody has asked for anything yet.
9816        assert!(!runner.park_here().expect("park_here"));
9817        assert!(!runner.state.parked);
9818
9819        // Only the interrupt handle fires; the shutdown handle stays clear.
9820        interrupt.park_because("test");
9821        assert!(!shutdown.parked());
9822        assert!(runner.park_here().expect("park_here"));
9823    }
9824
9825    /// The property every prior attempt at this feature failed to pin down:
9826    /// asking a run to park while one of its nodes has a real, in-flight
9827    /// async operation running (an agent call, in production) must not cut
9828    /// that operation short. `park_here` is only ever consulted *between*
9829    /// `execute`'s node calls - see its own doc - so nothing inside a node
9830    /// can observe a park request until the node itself returns. This proves
9831    /// that structurally, with real `tokio` concurrency and a channel
9832    /// handshake (never a sleep, which would only prove "usually", not
9833    /// "cannot"): the "node" below reports that it has genuinely started,
9834    /// and only then is the park requested; the node still has to be told to
9835    /// finish before `park_here` is ever called, exactly mirroring every
9836    /// `self.some_node().await; if self.park_here()? { return Ok(()); }` pair
9837    /// in `execute`.
9838    #[tokio::test]
9839    async fn a_park_request_made_mid_node_only_takes_effect_at_the_next_boundary() {
9840        crate::run::pin_test_home();
9841        let mut runner = runner_at(RunStatus::Implementing);
9842        let interrupt = Pause::new();
9843        runner.watch_interrupt(interrupt.clone());
9844
9845        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
9846        let (finish_tx, finish_rx) = tokio::sync::oneshot::channel::<()>();
9847
9848        // Stands in for one node's in-flight agent call: it proves it has
9849        // genuinely started, then blocks - exactly as a spawned CLI process
9850        // does - until told to finish.
9851        let node = async move {
9852            started_tx.send(()).expect("send started");
9853            finish_rx.await.expect("recv finish");
9854            "node finished"
9855        };
9856
9857        let interrupter = async move {
9858            started_rx.await.expect("recv started");
9859            // The call is now genuinely in flight. Ask it to park.
9860            interrupt.park_because("higher-priority task waiting");
9861            // Nothing the node does can observe this yet - there is no
9862            // check inside it, by construction - so let the executor run
9863            // anything pending and then let the node finish on its own.
9864            tokio::task::yield_now().await;
9865            finish_tx.send(()).expect("send finish");
9866        };
9867
9868        let (node_result, ()) = tokio::join!(node, interrupter);
9869        assert_eq!(
9870            node_result, "node finished",
9871            "the in-flight call ran to completion"
9872        );
9873
9874        // Only now, at the boundary the real `execute` would check right
9875        // after this node, does the park take effect.
9876        assert!(runner.park_here().expect("park_here"));
9877        assert!(runner.state.parked);
9878    }
9879
9880    /// A run parked mid-competition carries every field it had accumulated
9881    /// through the exact same disk round-trip an ordinary resume uses -
9882    /// `RunState::save`/`RunState::load`, which is all `Runner::resume` is.
9883    /// Nothing about parking for an interrupt is a special case of that path;
9884    /// this is what proves it rather than assuming it.
9885    #[test]
9886    fn a_run_parked_for_an_interrupt_resumes_with_nothing_lost() {
9887        crate::run::pin_test_home();
9888        let mut runner = runner_at(RunStatus::Judging);
9889        // `Runner::resume` re-resolves roles from the saved config, which
9890        // refuses an empty roster - give it the same minimal one `conductor`
9891        // itself uses.
9892        runner.state.config.agents = vec![conductor()];
9893        runner.state.candidates = vec![Candidate {
9894            index: 0,
9895            label: 'A',
9896            agent: "alpha".to_owned(),
9897            branch: "magi/x/A".to_owned(),
9898            worktree: PathBuf::from("/nonexistent/worktree"),
9899            summary: "did the thing".to_owned(),
9900            stat: "1 file changed".to_owned(),
9901            files: 1,
9902            commits: 1,
9903            empty: false,
9904            failed: None,
9905            verified_noop: None,
9906            duration_ms: 1234,
9907            folded: false,
9908        }];
9909        let run_id = runner.state.id.clone();
9910
9911        let interrupt = Pause::new();
9912        runner.watch_interrupt(interrupt.clone());
9913        interrupt.park_because("task c3d4 asked to run first");
9914        assert!(runner.park_here().expect("park_here"));
9915
9916        let resumed = Runner::resume(&run_id).expect("resume");
9917        assert_eq!(resumed.state.candidates.len(), 1);
9918        assert_eq!(resumed.state.candidates[0].summary, "did the thing");
9919        assert_eq!(resumed.state.candidates[0].branch, "magi/x/A");
9920        assert_eq!(resumed.state.status, runner.state.status);
9921        assert!(
9922            resumed.state.parked,
9923            "still parked until `execute` actually walks the graph again"
9924        );
9925        assert!(resumed.state.events.iter().any(|e| e.node == "park"));
9926    }
9927
9928    /// A fresh open question on `run`, stored and handed back for assertions.
9929    fn ask_open_question(store: &ask::Questions, run: &str) -> ask::Question {
9930        let mut q = ask::Question::new(
9931            run.to_owned(),
9932            "implement".to_owned(),
9933            "impl-A".to_owned(),
9934            "Which storage backend should the cache use?".to_owned(),
9935            String::new(),
9936            vec!["SQLite".to_owned(), "Redis".to_owned()],
9937        );
9938        store.put(&mut q).unwrap();
9939        q
9940    }
9941
9942    #[test]
9943    fn a_failed_runs_open_question_is_abandoned() {
9944        ask_test_home();
9945        let store = ask::Questions::open();
9946        let mut runner = runner_at(RunStatus::Failed);
9947        let run = runner.state.id.clone();
9948        let q = ask_open_question(&store, &run);
9949
9950        runner.settle_questions();
9951
9952        let back = store.get(&q.id).unwrap();
9953        assert!(
9954            !back.status.open(),
9955            "the seat that asked died with the run; nobody is left to read an answer"
9956        );
9957        assert!(
9958            back.detail.contains(&run) && back.detail.contains("failed"),
9959            "the reason names what the run became, not just that it is gone: {}",
9960            back.detail
9961        );
9962    }
9963
9964    #[test]
9965    fn a_merged_runs_open_question_is_abandoned_too() {
9966        ask_test_home();
9967        let store = ask::Questions::open();
9968        // A run that finishes cleanly still leaves nobody to read an answer -
9969        // this is not only a failure-path cleanup.
9970        for status in [RunStatus::Merged, RunStatus::Ready] {
9971            let mut runner = runner_at(status);
9972            let run = runner.state.id.clone();
9973            let q = ask_open_question(&store, &run);
9974
9975            runner.settle_questions();
9976
9977            let back = store.get(&q.id).unwrap();
9978            assert!(
9979                !back.status.open(),
9980                "{status:?} run's question must not outlive the run"
9981            );
9982        }
9983    }
9984
9985    #[test]
9986    fn a_still_resumable_runs_open_question_is_left_alone() {
9987        ask_test_home();
9988        let store = ask::Questions::open();
9989        // `Blocked` and `Stalled` can still be resumed — the candidates, the
9990        // review round and the seat sessions are all still on disk — so a
9991        // question asked mid-round may yet get a real answer from a real
9992        // resume. Sweeping it here would be exactly the failure mode this
9993        // whole feature exists to avoid on the other side.
9994        for status in [RunStatus::Blocked, RunStatus::Stalled] {
9995            let mut runner = runner_at(status);
9996            let run = runner.state.id.clone();
9997            let q = ask_open_question(&store, &run);
9998
9999            runner.settle_questions();
10000
10001            let back = store.get(&q.id).unwrap();
10002            assert!(
10003                back.status.open(),
10004                "{status:?} is still alive; the question must still be waiting"
10005            );
10006        }
10007    }
10008
10009    #[test]
10010    fn settle_questions_never_touches_an_already_answered_question() {
10011        ask_test_home();
10012        let store = ask::Questions::open();
10013        let mut runner = runner_at(RunStatus::Failed);
10014        let run = runner.state.id.clone();
10015        let mut q = ask_open_question(&store, &run);
10016        q.answer(crate::ask::Answer::Choice("SQLite".to_owned()))
10017            .unwrap();
10018        store.put(&mut q).unwrap();
10019
10020        // Called twice, the way a crash-recovered daemon reclaim and the
10021        // graph's own cleanup both can for the same run — `abandon_for_run`
10022        // only ever touches what is still open, so this must be inert both
10023        // times, not merely the second.
10024        runner.settle_questions();
10025        runner.settle_questions();
10026
10027        let back = store.get(&q.id).unwrap();
10028        assert_eq!(
10029            back.status,
10030            ask::QuestionStatus::Answered,
10031            "a real answer is a decision on record, never overwritten by a sweep"
10032        );
10033    }
10034
10035    /// `fold_run(&mut state, drop_winner = false)` is exactly the call
10036    /// `clean::fold_due` makes for a `Ready`/`Failed` run - one that finished
10037    /// without merging, whose winner is still the operator's answer to read.
10038    /// Nothing previously called `fold_run` itself with a real `tally`, so
10039    /// this is the first test to pin down the one distinction the whole
10040    /// automatic-fold feature depends on: the winner's worktree and branch
10041    /// must survive, everything else sharing the run's worktree bay - a
10042    /// loser, standing in for a judge/review worktree too, since `fold_run`'s
10043    /// second sweep treats every non-winner directory under the bay alike -
10044    /// must not.
10045    #[tokio::test]
10046    async fn fold_run_keeps_only_the_winner_when_the_winner_is_not_dropped() {
10047        crate::run::pin_test_home();
10048        let tmp = tempfile::tempdir().expect("tempdir");
10049        let repo = tmp.path().join("repo");
10050        std::fs::create_dir_all(&repo).unwrap();
10051        init_repo(&repo);
10052
10053        let mut config = Config::default();
10054        config.graph.worktree_root = Some(tmp.path().join("wt"));
10055
10056        let mut state = RunState::new(
10057            repo.clone(),
10058            "main".to_owned(),
10059            "deadbeef".to_owned(),
10060            "task".to_owned(),
10061            config,
10062        );
10063        let root = state.worktree_root();
10064        let wt_a = root.join("cand-A");
10065        let wt_b = root.join("cand-B");
10066        git::worktree_add_branch(&repo, &wt_a, "magi/x/A", "main")
10067            .await
10068            .expect("worktree A");
10069        git::worktree_add_branch(&repo, &wt_b, "magi/x/B", "main")
10070            .await
10071            .expect("worktree B");
10072
10073        state.candidates = vec![
10074            Candidate {
10075                index: 0,
10076                label: 'A',
10077                agent: "alpha".to_owned(),
10078                branch: "magi/x/A".to_owned(),
10079                worktree: wt_a.clone(),
10080                summary: String::new(),
10081                stat: String::new(),
10082                files: 0,
10083                commits: 0,
10084                empty: false,
10085                failed: None,
10086                verified_noop: None,
10087                duration_ms: 0,
10088                folded: false,
10089            },
10090            Candidate {
10091                index: 1,
10092                label: 'B',
10093                agent: "beta".to_owned(),
10094                branch: "magi/x/B".to_owned(),
10095                worktree: wt_b.clone(),
10096                summary: String::new(),
10097                stat: String::new(),
10098                files: 0,
10099                commits: 0,
10100                empty: false,
10101                failed: None,
10102                verified_noop: None,
10103                duration_ms: 0,
10104                folded: false,
10105            },
10106        ];
10107        state.tally = Some(Tally {
10108            first_choice: BTreeMap::from([('A', 1)]),
10109            borda: BTreeMap::new(),
10110            winner: 'A',
10111            rankings: 1,
10112            unanimous_initial: true,
10113            deliberated: false,
10114            changed_votes: 0,
10115            unanimous_final: true,
10116            tie_break: None,
10117            judges: 1,
10118            present: 1,
10119            quorum: 1,
10120            met_quorum: true,
10121            uncontested: None,
10122        });
10123        state.status = RunStatus::Ready;
10124
10125        fold_run(&mut state, false, &crate::run::home())
10126            .await
10127            .expect("fold_run");
10128
10129        assert!(wt_a.exists(), "the unmerged winner's worktree survives");
10130        assert!(
10131            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10132            "the unmerged winner's branch survives"
10133        );
10134        assert!(
10135            !state.candidates[0].folded,
10136            "the winner is not marked folded"
10137        );
10138
10139        assert!(!wt_b.exists(), "the loser's worktree is removed");
10140        assert!(
10141            !git::branch_exists(&repo, "magi/x/B").await.unwrap(),
10142            "the loser's branch is removed"
10143        );
10144        assert!(state.candidates[1].folded, "the loser is marked folded");
10145    }
10146
10147    /// A branch handed to a later run is that run's (and its pull request's):
10148    /// folding the run that released it must not delete it.
10149    #[tokio::test]
10150    async fn fold_run_keeps_a_branch_that_was_handed_to_a_later_run() {
10151        let tmp = tempfile::tempdir().expect("tempdir");
10152        let repo = tmp.path().join("repo");
10153        std::fs::create_dir_all(&repo).unwrap();
10154        init_repo(&repo);
10155        let home = tmp.path().join("home");
10156
10157        let mut config = Config::default();
10158        config.graph.worktree_root = Some(tmp.path().join("wt"));
10159        let mut state = RunState::new(
10160            repo.clone(),
10161            "main".to_owned(),
10162            "deadbeef".to_owned(),
10163            "task".to_owned(),
10164            config,
10165        );
10166        // The worktree is already gone (released); the branch survives.
10167        git::git(&repo, &["branch", "magi/x/A", "main"])
10168            .await
10169            .expect("branch");
10170        state.candidates = vec![Candidate {
10171            index: 0,
10172            label: 'A',
10173            agent: "alpha".to_owned(),
10174            branch: "magi/x/A".to_owned(),
10175            worktree: state.worktree_root().join("cand-A"),
10176            summary: String::new(),
10177            stat: String::new(),
10178            files: 0,
10179            commits: 0,
10180            empty: false,
10181            failed: None,
10182            verified_noop: None,
10183            duration_ms: 0,
10184            folded: true,
10185        }];
10186        state.released_to = Some("20260901-000000-new1".to_owned());
10187        state.released_branches = vec!["magi/x/A".to_owned()];
10188
10189        fold_run(&mut state, true, &home).await.expect("fold_run");
10190
10191        assert!(
10192            git::branch_exists(&repo, "magi/x/A").await.unwrap(),
10193            "the handed-over branch survives a fold"
10194        );
10195    }
10196
10197    /// A winner with nothing ahead of the base is caught before `gh` is ever
10198    /// asked for a pull request, and the message carries what the task's
10199    /// references resolved to.
10200    #[tokio::test]
10201    async fn an_empty_winner_is_detected_before_a_pull_request_is_attempted() {
10202        let tmp = tempfile::tempdir().expect("tempdir");
10203        let repo = tmp.path().join("repo");
10204        std::fs::create_dir_all(&repo).unwrap();
10205        init_repo(&repo);
10206        let run = |args: &[&str]| {
10207            let out = std::process::Command::new("git")
10208                .quiet()
10209                .args(args)
10210                .current_dir(&repo)
10211                .output()
10212                .expect("spawn git");
10213            assert!(out.status.success(), "git {args:?}");
10214        };
10215        run(&["branch", "magi/x/A"]);
10216        run(&["checkout", "-q", "-b", "magi/x/B"]);
10217        std::fs::write(repo.join("f.txt"), "x\n").unwrap();
10218        run(&["add", "-A"]);
10219        run(&["commit", "-q", "-m", "work"]);
10220        run(&["checkout", "-q", "main"]);
10221
10222        // A pull request is compared against the remote's base, so the
10223        // fixture needs one. Before it exists nothing can be read, and the
10224        // answer must be "not empty".
10225        let probe = RunState::new(
10226            repo.clone(),
10227            "main".to_owned(),
10228            "deadbeef".to_owned(),
10229            "task".to_owned(),
10230            Config::default(),
10231        );
10232        assert!(!merge_is_empty(&repo, &probe, "magi/x/A", MergeMode::Pr).await);
10233        let bare = tmp.path().join("origin.git");
10234        let out = std::process::Command::new("git")
10235            .quiet()
10236            .args(["init", "-q", "--bare"])
10237            .arg(&bare)
10238            .output()
10239            .expect("spawn git");
10240        assert!(out.status.success(), "git init --bare");
10241        run(&["remote", "add", "origin", bare.to_str().unwrap()]);
10242        run(&["push", "-q", "origin", "main"]);
10243
10244        let mut state = RunState::new(
10245            repo.clone(),
10246            "main".to_owned(),
10247            "deadbeef".to_owned(),
10248            "task".to_owned(),
10249            Config::default(),
10250        );
10251        state.seeds = vec![refs::Seed {
10252            token: "magi/27b2/A".to_owned(),
10253            kind: refs::SeedKind::Unresolved,
10254            sha: String::new(),
10255            branch: true,
10256            detail: "no branch or commit named magi/27b2/A".to_owned(),
10257        }];
10258
10259        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Pr).await);
10260        assert!(merge_is_empty(&repo, &state, "magi/x/A", MergeMode::Local).await);
10261        assert!(!merge_is_empty(&repo, &state, "magi/x/B", MergeMode::Pr).await);
10262        let detail = empty_candidate_detail(&state, "main");
10263        assert!(detail.starts_with("empty candidate"), "{detail}");
10264        assert!(detail.contains("magi/27b2/A"), "{detail}");
10265    }
10266
10267    /// `status == Ready` used to be read as "this is the harmless
10268    /// `MergeMode::None` no-op path, nothing to guard" (graph.rs, prior to
10269    /// this test). But `land` sets the very same status when a `MergeMode::Pr`
10270    /// run's PR was closed without merging — and reentering `merge` with
10271    /// `mode` still `Pr` does not know the difference, so it pushed and
10272    /// opened a second pull request. `mode == Local` reproduces the same
10273    /// blind spot without a network call: reentry must not attempt another
10274    /// git merge once this node has already recorded an outcome.
10275    #[tokio::test]
10276    async fn merge_does_not_reattempt_once_a_run_has_concluded() {
10277        let tmp = tempfile::tempdir().expect("tempdir");
10278        let repo = tmp.path().join("repo");
10279        std::fs::create_dir_all(&repo).unwrap();
10280        init_repo(&repo);
10281
10282        let mut config = Config::default();
10283        config.merge.mode = MergeMode::Local;
10284
10285        let mut state = RunState::new(
10286            repo.clone(),
10287            "main".to_owned(),
10288            "deadbeef".to_owned(),
10289            "task".to_owned(),
10290            config,
10291        );
10292        state.candidates = vec![Candidate {
10293            index: 0,
10294            label: 'A',
10295            agent: "alpha".to_owned(),
10296            branch: "does-not-exist".to_owned(),
10297            worktree: repo.clone(),
10298            summary: String::new(),
10299            stat: String::new(),
10300            files: 0,
10301            commits: 0,
10302            empty: false,
10303            failed: None,
10304            verified_noop: None,
10305            duration_ms: 0,
10306            folded: false,
10307        }];
10308        state.tally = Some(Tally {
10309            first_choice: BTreeMap::from([('A', 1)]),
10310            borda: BTreeMap::new(),
10311            winner: 'A',
10312            rankings: 1,
10313            unanimous_initial: true,
10314            deliberated: false,
10315            changed_votes: 0,
10316            unanimous_final: true,
10317            tie_break: None,
10318            judges: 0,
10319            present: 0,
10320            quorum: 0,
10321            met_quorum: true,
10322            uncontested: Some("only candidate A produced a change".to_owned()),
10323        });
10324        state.reviews = vec![ReviewRound {
10325            round: 1,
10326            head: "deadbeef".to_owned(),
10327            verified_head: None,
10328            verified_at: None,
10329            reviews: Vec::new(),
10330            e2e: Vec::new(),
10331            fix: None,
10332            blocking: 0,
10333            answered: 0,
10334            expected: 0,
10335            clean: true,
10336            verify_retried: false,
10337            e2e_deferred: false,
10338            e2e_defer_reason: None,
10339            progressed: false,
10340            vote_split: false,
10341            reconsideration: Vec::new(),
10342            verdict: None,
10343        }];
10344        state.gate = vec![CommandOutcome {
10345            command: "test".to_owned(),
10346            code: Some(0),
10347            output_tail: String::new(),
10348            duration_ms: 0,
10349            resource_blocked: false,
10350        }];
10351        state.gate_ran = true;
10352        // Reached its conclusion already — e.g. `land` closing the PR without
10353        // merging it, which (like the honest `MergeMode::None` path) leaves
10354        // `status` at `Ready`. The recorded outcome is what actually marks
10355        // this node done.
10356        state.status = RunStatus::Ready;
10357        state.merge = Some(MergeOutcome {
10358            mode: MergeMode::Local,
10359            ok: false,
10360            detail: "already concluded".to_owned(),
10361            empty: false,
10362        });
10363
10364        let mut runner = Runner {
10365            state,
10366            roles: ResolvedRoles {
10367                implementers: Vec::new(),
10368                judges: Vec::new(),
10369                reviewers: Vec::new(),
10370                fixer: None,
10371                conductor: conductor(),
10372                implementer_roster: Vec::new(),
10373                judge_roster: Vec::new(),
10374                reviewer_roster: Vec::new(),
10375            },
10376            sem: Arc::new(Semaphore::new(1)),
10377            pause: Pause::new(),
10378            interrupt: Pause::new(),
10379        };
10380
10381        runner.merge().await.expect("merge");
10382
10383        assert_eq!(
10384            runner.state.status,
10385            RunStatus::Ready,
10386            "a concluded run's status must not change on reentry"
10387        );
10388        assert_eq!(
10389            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
10390            Some("already concluded"),
10391            "merge must not run again once the node already recorded an outcome"
10392        );
10393    }
10394
10395    /// `gate` leaves `state.gate_ran` false both before it has ever run and
10396    /// when its last attempt was resource-blocked (the shared build cache
10397    /// could not be acquired or confirmed fresh in time - see
10398    /// `CommandOutcome::resource_blocked`'s own doc). Trusting the empty
10399    /// `Vec` this also leaves behind used to read as "nothing failed" and let
10400    /// a run merge a tree the gate never actually checked - exactly the case
10401    /// a contended cache produces on every retry until it clears. `merge`
10402    /// must refuse until `gate` has actually recorded an attempt.
10403    #[tokio::test]
10404    async fn merge_refuses_a_gate_that_has_not_actually_run() {
10405        let tmp = tempfile::tempdir().expect("tempdir");
10406        let repo = tmp.path().join("repo");
10407        std::fs::create_dir_all(&repo).unwrap();
10408        init_repo(&repo);
10409
10410        let mut config = Config::default();
10411        config.merge.mode = MergeMode::Local;
10412
10413        let mut state = RunState::new(
10414            repo.clone(),
10415            "main".to_owned(),
10416            "deadbeef".to_owned(),
10417            "task".to_owned(),
10418            config,
10419        );
10420        state.candidates = vec![Candidate {
10421            index: 0,
10422            label: 'A',
10423            agent: "alpha".to_owned(),
10424            branch: "does-not-exist".to_owned(),
10425            worktree: repo.clone(),
10426            summary: String::new(),
10427            stat: String::new(),
10428            files: 0,
10429            commits: 0,
10430            empty: false,
10431            failed: None,
10432            verified_noop: None,
10433            duration_ms: 0,
10434            folded: false,
10435        }];
10436        state.tally = Some(Tally {
10437            first_choice: BTreeMap::from([('A', 1)]),
10438            borda: BTreeMap::new(),
10439            winner: 'A',
10440            rankings: 1,
10441            unanimous_initial: true,
10442            deliberated: false,
10443            changed_votes: 0,
10444            unanimous_final: true,
10445            tie_break: None,
10446            judges: 0,
10447            present: 0,
10448            quorum: 0,
10449            met_quorum: true,
10450            uncontested: Some("only candidate A produced a change".to_owned()),
10451        });
10452        state.reviews = vec![ReviewRound {
10453            round: 1,
10454            head: "deadbeef".to_owned(),
10455            verified_head: None,
10456            verified_at: None,
10457            reviews: Vec::new(),
10458            e2e: Vec::new(),
10459            fix: None,
10460            blocking: 0,
10461            answered: 0,
10462            expected: 0,
10463            clean: true,
10464            verify_retried: false,
10465            e2e_deferred: false,
10466            e2e_defer_reason: None,
10467            progressed: false,
10468            vote_split: false,
10469            reconsideration: Vec::new(),
10470            verdict: None,
10471        }];
10472        // The point: `gate` has not recorded anything yet.
10473        state.gate = Vec::new();
10474        state.gate_ran = false;
10475        state.status = RunStatus::Gating;
10476
10477        let mut runner = Runner {
10478            state,
10479            roles: ResolvedRoles {
10480                implementers: Vec::new(),
10481                judges: Vec::new(),
10482                reviewers: Vec::new(),
10483                fixer: None,
10484                conductor: conductor(),
10485                implementer_roster: Vec::new(),
10486                judge_roster: Vec::new(),
10487                reviewer_roster: Vec::new(),
10488            },
10489            sem: Arc::new(Semaphore::new(1)),
10490            pause: Pause::new(),
10491            interrupt: Pause::new(),
10492        };
10493
10494        runner.merge().await.expect("merge");
10495
10496        assert!(
10497            runner.state.merge.is_none(),
10498            "an empty gate must never be read as a passing one: {:?}",
10499            runner.state.merge
10500        );
10501    }
10502
10503    /// The `shoka` repro this schema bump exists for: `verify.gate` has no
10504    /// commands configured and `merge.mode` is `none` (a review-only run).
10505    /// `gate` must still record a real attempt — zero commands, vacuously
10506    /// passed — rather than leaving `state.gate` empty in a way `merge`
10507    /// cannot tell apart from "never ran"; otherwise the run reaches
10508    /// `Gating` and can never leave it. See `RunState::gate_ran`'s own doc.
10509    #[tokio::test]
10510    async fn gate_and_merge_reach_ready_when_no_gate_commands_are_configured() {
10511        let tmp = tempfile::tempdir().expect("tempdir");
10512        let repo = tmp.path().join("repo");
10513        std::fs::create_dir_all(&repo).unwrap();
10514        init_repo(&repo);
10515
10516        // Default config: `verify.gate` empty, `merge.mode` is `none`.
10517        let config = Config::default();
10518
10519        let mut state = RunState::new(
10520            repo.clone(),
10521            "main".to_owned(),
10522            "deadbeef".to_owned(),
10523            "task".to_owned(),
10524            config,
10525        );
10526        state.candidates = vec![Candidate {
10527            index: 0,
10528            label: 'A',
10529            agent: "alpha".to_owned(),
10530            branch: "does-not-exist".to_owned(),
10531            worktree: repo.clone(),
10532            summary: String::new(),
10533            stat: String::new(),
10534            files: 0,
10535            commits: 0,
10536            empty: false,
10537            failed: None,
10538            verified_noop: None,
10539            duration_ms: 0,
10540            folded: false,
10541        }];
10542        state.tally = Some(Tally {
10543            first_choice: BTreeMap::from([('A', 1)]),
10544            borda: BTreeMap::new(),
10545            winner: 'A',
10546            rankings: 1,
10547            unanimous_initial: true,
10548            deliberated: false,
10549            changed_votes: 0,
10550            unanimous_final: true,
10551            tie_break: None,
10552            judges: 0,
10553            present: 0,
10554            quorum: 0,
10555            met_quorum: true,
10556            uncontested: Some("only candidate A produced a change".to_owned()),
10557        });
10558        state.reviews = vec![ReviewRound {
10559            round: 1,
10560            head: "deadbeef".to_owned(),
10561            verified_head: None,
10562            verified_at: None,
10563            reviews: Vec::new(),
10564            e2e: Vec::new(),
10565            fix: None,
10566            blocking: 0,
10567            answered: 0,
10568            expected: 0,
10569            clean: true,
10570            verify_retried: false,
10571            e2e_deferred: false,
10572            e2e_defer_reason: None,
10573            progressed: false,
10574            vote_split: false,
10575            reconsideration: Vec::new(),
10576            verdict: None,
10577        }];
10578
10579        let mut runner = Runner {
10580            state,
10581            roles: ResolvedRoles {
10582                implementers: Vec::new(),
10583                judges: Vec::new(),
10584                reviewers: Vec::new(),
10585                fixer: None,
10586                conductor: conductor(),
10587                implementer_roster: Vec::new(),
10588                judge_roster: Vec::new(),
10589                reviewer_roster: Vec::new(),
10590            },
10591            sem: Arc::new(Semaphore::new(1)),
10592            pause: Pause::new(),
10593            interrupt: Pause::new(),
10594        };
10595
10596        runner.gate().await.expect("gate");
10597        assert!(
10598            runner.state.gate_ran,
10599            "zero configured commands is still a real attempt, not an unrun gate"
10600        );
10601        assert!(runner.state.gate.is_empty());
10602        assert_eq!(runner.state.gate_status(), GateStatus::PassedWithNoCommands);
10603        assert_ne!(
10604            runner.state.status,
10605            RunStatus::Blocked,
10606            "a gate with nothing to check must not read as failed"
10607        );
10608
10609        runner.merge().await.expect("merge");
10610        assert_eq!(
10611            runner.state.status,
10612            RunStatus::Ready,
10613            "a clean review-only run with no gate commands must reach Ready, not stay stuck in Gating"
10614        );
10615    }
10616
10617    /// `Config::cache_dir` is derived from `verify.e2e` as well as
10618    /// `verify.gate` (so the e2e leg and the final gate never build against
10619    /// different directories). With zero `verify.gate` commands but a
10620    /// `CARGO_TARGET_DIR`-using `verify.e2e`, `gate` used to still queue for
10621    /// that lease before discovering it had nothing to run - so a repo with
10622    /// no gate commands could come back `resource_blocked` (and therefore
10623    /// still `gate_ran == false`) on nothing but an unrelated run holding the
10624    /// cache, exactly the contention this run's own zero commands could
10625    /// never have touched. `gate` must recognise there is nothing to check
10626    /// before it ever asks for the lease.
10627    #[tokio::test]
10628    async fn gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run() {
10629        crate::run::pin_test_home();
10630        let home = crate::run::home();
10631
10632        let tmp = tempfile::tempdir().expect("tempdir");
10633        let repo = tmp.path().join("repo");
10634        std::fs::create_dir_all(&repo).unwrap();
10635        init_repo(&repo);
10636        // Unique to this test, so holding its lease cannot collide with
10637        // another test sharing the same process-wide `home`.
10638        let cache_dir = tmp.path().join("target");
10639
10640        let mut config = Config::default();
10641        config.verify.e2e = vec![format!("CARGO_TARGET_DIR='{}' true", cache_dir.display())];
10642        // `verify.gate` stays empty (the default). Bounded so a regression
10643        // that does start waiting fails the test in seconds, not hangs it.
10644        config.graph.timeout_verify = Some(2);
10645
10646        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
10647        let _held = match crate::cache::try_acquire(&home, &cache_dir, &other)
10648            .expect("no io error acquiring directly")
10649        {
10650            crate::cache::AcquireOutcome::Acquired(g) => g,
10651            crate::cache::AcquireOutcome::Busy(b) => {
10652                panic!("expected the direct acquire to win the lease first: {b:?}")
10653            }
10654        };
10655
10656        let mut state = RunState::new(
10657            repo.clone(),
10658            "main".to_owned(),
10659            "deadbeef".to_owned(),
10660            "task".to_owned(),
10661            config,
10662        );
10663        state.candidates = vec![Candidate {
10664            index: 0,
10665            label: 'A',
10666            agent: "alpha".to_owned(),
10667            branch: "does-not-exist".to_owned(),
10668            worktree: repo.clone(),
10669            summary: String::new(),
10670            stat: String::new(),
10671            files: 0,
10672            commits: 0,
10673            empty: false,
10674            failed: None,
10675            verified_noop: None,
10676            duration_ms: 0,
10677            folded: false,
10678        }];
10679        state.tally = Some(Tally {
10680            first_choice: BTreeMap::from([('A', 1)]),
10681            borda: BTreeMap::new(),
10682            winner: 'A',
10683            rankings: 1,
10684            unanimous_initial: true,
10685            deliberated: false,
10686            changed_votes: 0,
10687            unanimous_final: true,
10688            tie_break: None,
10689            judges: 0,
10690            present: 0,
10691            quorum: 0,
10692            met_quorum: true,
10693            uncontested: Some("only candidate A produced a change".to_owned()),
10694        });
10695        state.reviews = vec![ReviewRound {
10696            round: 1,
10697            head: "deadbeef".to_owned(),
10698            verified_head: None,
10699            verified_at: None,
10700            reviews: Vec::new(),
10701            e2e: Vec::new(),
10702            fix: None,
10703            blocking: 0,
10704            answered: 0,
10705            expected: 0,
10706            clean: true,
10707            verify_retried: false,
10708            e2e_deferred: false,
10709            e2e_defer_reason: None,
10710            progressed: false,
10711            vote_split: false,
10712            reconsideration: Vec::new(),
10713            verdict: None,
10714        }];
10715
10716        let mut runner = Runner {
10717            state,
10718            roles: ResolvedRoles {
10719                implementers: Vec::new(),
10720                judges: Vec::new(),
10721                reviewers: Vec::new(),
10722                fixer: None,
10723                conductor: conductor(),
10724                implementer_roster: Vec::new(),
10725                judge_roster: Vec::new(),
10726                reviewer_roster: Vec::new(),
10727            },
10728            sem: Arc::new(Semaphore::new(1)),
10729            pause: Pause::new(),
10730            interrupt: Pause::new(),
10731        };
10732
10733        let started = std::time::Instant::now();
10734        runner.gate().await.expect("gate");
10735        assert!(
10736            started.elapsed() < Duration::from_secs(1),
10737            "a gate with nothing to run must never wait on a lease it never needed"
10738        );
10739        assert!(
10740            runner.state.gate_ran,
10741            "zero commands is still a real, immediate attempt"
10742        );
10743        assert!(runner.state.gate.is_empty());
10744        assert_ne!(
10745            runner.state.status,
10746            RunStatus::Blocked,
10747            "must not read as resource-blocked on a lease it never asked for"
10748        );
10749    }
10750
10751    /// The addendum's second gap: a `verify.gate` command running for real
10752    /// wall-clock time had nothing at all to show for it in `active` before
10753    /// `run_commands` learned to record it — a run could sit in `Gating` for
10754    /// minutes with `magi show` and `GET /api/runs/{id}` both silent about
10755    /// what was actually happening. Proven with a genuinely still-running
10756    /// command, not just a before/after check on the final state: a poller
10757    /// task reads the same `run.json` `gate()` is writing, the same way the
10758    /// phone or `magi show` would, while the shell command is still blocked
10759    /// on its own release marker.
10760    #[tokio::test]
10761    async fn gate_records_a_running_task_entry_while_its_command_is_still_in_flight() {
10762        crate::run::pin_test_home();
10763
10764        let tmp = tempfile::tempdir().expect("tempdir");
10765        let repo = tmp.path().join("repo");
10766        std::fs::create_dir_all(&repo).unwrap();
10767        init_repo(&repo);
10768
10769        let mut config = Config::default();
10770        config.verify.gate = vec![
10771            "printf started > started.marker; i=0; while [ ! -f release.marker ] && \
10772             [ \"$i\" -lt 100 ]; do i=$((i+1)); sleep 0.05; done"
10773                .to_owned(),
10774        ];
10775
10776        let mut state = RunState::new(
10777            repo.clone(),
10778            "main".to_owned(),
10779            "deadbeef".to_owned(),
10780            "task".to_owned(),
10781            config,
10782        );
10783        let run_id = state.id.clone();
10784        state.candidates = vec![Candidate {
10785            index: 0,
10786            label: 'A',
10787            agent: "alpha".to_owned(),
10788            branch: "does-not-exist".to_owned(),
10789            worktree: repo.clone(),
10790            summary: String::new(),
10791            stat: String::new(),
10792            files: 0,
10793            commits: 0,
10794            empty: false,
10795            failed: None,
10796            verified_noop: None,
10797            duration_ms: 0,
10798            folded: false,
10799        }];
10800        state.tally = Some(Tally {
10801            first_choice: BTreeMap::from([('A', 1)]),
10802            borda: BTreeMap::new(),
10803            winner: 'A',
10804            rankings: 1,
10805            unanimous_initial: true,
10806            deliberated: false,
10807            changed_votes: 0,
10808            unanimous_final: true,
10809            tie_break: None,
10810            judges: 0,
10811            present: 0,
10812            quorum: 0,
10813            met_quorum: true,
10814            uncontested: Some("only candidate A produced a change".to_owned()),
10815        });
10816        state.reviews = vec![ReviewRound {
10817            round: 1,
10818            head: "deadbeef".to_owned(),
10819            verified_head: None,
10820            verified_at: None,
10821            reviews: Vec::new(),
10822            e2e: Vec::new(),
10823            fix: None,
10824            blocking: 0,
10825            answered: 0,
10826            expected: 0,
10827            clean: true,
10828            verify_retried: false,
10829            e2e_deferred: false,
10830            e2e_defer_reason: None,
10831            progressed: false,
10832            vote_split: false,
10833            reconsideration: Vec::new(),
10834            verdict: None,
10835        }];
10836
10837        let mut runner = Runner {
10838            state,
10839            roles: ResolvedRoles {
10840                implementers: Vec::new(),
10841                judges: Vec::new(),
10842                reviewers: Vec::new(),
10843                fixer: None,
10844                conductor: conductor(),
10845                implementer_roster: Vec::new(),
10846                judge_roster: Vec::new(),
10847                reviewer_roster: Vec::new(),
10848            },
10849            sem: Arc::new(Semaphore::new(1)),
10850            pause: Pause::new(),
10851            interrupt: Pause::new(),
10852        };
10853
10854        let started_marker = repo.join("started.marker");
10855        let release_marker = repo.join("release.marker");
10856        let poller = tokio::spawn(async move {
10857            // Bounded so a regression that never records the task entry
10858            // fails this test in seconds instead of hanging the suite —
10859            // the same shape `a_park_requested_while_a_seat_is_mid_call_
10860            // does_not_cut_it_short` uses for the same reason.
10861            for _ in 0..100 {
10862                if started_marker.exists()
10863                    && let Ok(s) = crate::run::RunState::load(&run_id)
10864                    && let Some(a) = s.active.get("gate")
10865                {
10866                    std::fs::write(&release_marker, b"go").expect("release marker");
10867                    return Some(a.clone());
10868                }
10869                tokio::time::sleep(Duration::from_millis(50)).await;
10870            }
10871            None
10872        });
10873
10874        runner.gate().await.expect("gate");
10875        let captured = poller.await.expect("poller task");
10876        let captured = captured.expect(
10877            "the poller never saw a `gate` task entry in run.json while the command was \
10878             still blocked on its own release marker",
10879        );
10880
10881        assert_eq!(captured.task.as_deref(), Some("gate"));
10882        assert_eq!(captured.node, "gate");
10883        assert_eq!(captured.index, Some(1));
10884        assert_eq!(captured.total, Some(1));
10885        assert!(
10886            captured
10887                .command
10888                .as_deref()
10889                .is_some_and(|c| c.contains("started.marker")),
10890            "{captured:?}"
10891        );
10892
10893        assert!(
10894            runner.state.active.is_empty(),
10895            "the entry must be cleared once the command actually finished: {:?}",
10896            runner.state.active
10897        );
10898        assert!(runner.state.gate_ran);
10899        assert!(runner.state.gate.iter().all(CommandOutcome::ok));
10900    }
10901
10902    /// The hand-off over a blocking finding a reviewer rejected on leaves a
10903    /// record for `land`; one with only a Minor, or no reject, leaves none.
10904    #[tokio::test]
10905    async fn stop_reviewing_records_a_contested_hand_off_only_for_major_plus_reject() {
10906        use crate::verdict::{Finding, ReviewVote, Severity};
10907        crate::run::pin_test_home();
10908        let tmp = tempfile::tempdir().expect("tempdir");
10909        let repo = tmp.path().join("repo");
10910        std::fs::create_dir_all(&repo).unwrap();
10911        init_repo(&repo);
10912
10913        for (severity, vote, expect) in [
10914            (Severity::Major, ReviewVote::Reject, true),
10915            (Severity::Minor, ReviewVote::Reject, false),
10916            (Severity::Major, ReviewVote::Approve, false),
10917        ] {
10918            let mut round = review_round(false, 1, 1, 1, false, true);
10919            round.reviews = vec![ReviewRecord {
10920                reviewer: 1,
10921                agent: "alpha".to_owned(),
10922                summary: String::new(),
10923                findings: vec![Finding {
10924                    id: "R1-1-1".to_owned(),
10925                    severity,
10926                    file: None,
10927                    line: None,
10928                    title: "t".to_owned(),
10929                    detail: String::new(),
10930                }],
10931                vote: Some(vote),
10932                failed: None,
10933                duration_ms: 0,
10934                attempts: 0,
10935            }];
10936            let mut state = RunState::new(
10937                repo.clone(),
10938                "main".to_owned(),
10939                "deadbeef".to_owned(),
10940                "task".to_owned(),
10941                Config::default(),
10942            );
10943            state.reviews = vec![round];
10944            let mut runner = Runner {
10945                state,
10946                roles: ResolvedRoles {
10947                    implementers: Vec::new(),
10948                    judges: Vec::new(),
10949                    reviewers: Vec::new(),
10950                    fixer: None,
10951                    conductor: conductor(),
10952                    implementer_roster: Vec::new(),
10953                    judge_roster: Vec::new(),
10954                    reviewer_roster: Vec::new(),
10955                },
10956                sem: Arc::new(Semaphore::new(1)),
10957                pause: Pause::new(),
10958                interrupt: Pause::new(),
10959            };
10960            let shell = runner.state.config.shell();
10961            runner
10962                .stop_reviewing("round budget spent", &shell, &repo)
10963                .await
10964                .expect("stop_reviewing");
10965            assert_eq!(runner.state.status, RunStatus::Gating);
10966            assert_eq!(
10967                runner.state.contested_handoff.is_some(),
10968                expect,
10969                "{severity:?} + {vote:?}"
10970            );
10971        }
10972    }
10973
10974    /// The shape the incident this whole fix responds to actually had: the
10975    /// round budget spent, the last round's own e2e blocked on the shared
10976    /// build cache (held here by a live pid — this test process — exactly
10977    /// `cache`'s own unit tests' pattern for "another owner, still alive"
10978    /// without forking a process). `stop_reviewing` must retry it — not
10979    /// silently leave the round looking untouched (the catch-up-only half of
10980    /// the bug), and not read the contention as a red `e2e` and block the
10981    /// run on it (the other half). Called directly, the same way
10982    /// `gate_never_asks_for_the_cache_lease_when_it_has_no_commands_to_run`
10983    /// above exercises `gate`, so this never needs a real cargo build to
10984    /// reach: the lease is never released, so `with_cache_lease` never gets
10985    /// past acquiring it into anything that would need a real workspace.
10986    #[tokio::test]
10987    async fn stop_reviewing_retries_a_resource_blocked_e2e_instead_of_reading_it_as_red() {
10988        crate::run::pin_test_home();
10989        let home = crate::run::home();
10990
10991        let tmp = tempfile::tempdir().expect("tempdir");
10992        let repo = tmp.path().join("repo");
10993        std::fs::create_dir_all(&repo).unwrap();
10994        init_repo(&repo);
10995        let head = crate::git::rev_parse(&repo, "HEAD")
10996            .await
10997            .expect("rev-parse");
10998        // Unique to this test, so holding its lease cannot collide with
10999        // another test sharing the same process-wide `home`.
11000        let cache_dir = tmp.path().join("target");
11001
11002        let mut config = Config::default();
11003        config.verify.e2e = vec![format!(
11004            "CARGO_TARGET_DIR='{}' test -f README.md",
11005            cache_dir.display()
11006        )];
11007        config.graph.review_rounds = 1;
11008        // Bounded so a regression that does start waiting fails the test in
11009        // seconds, not hangs it.
11010        config.graph.timeout_verify = Some(2);
11011
11012        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11013        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11014            .expect("no io error acquiring directly")
11015        {
11016            crate::cache::AcquireOutcome::Acquired(g) => g,
11017            crate::cache::AcquireOutcome::Busy(b) => {
11018                panic!("expected the direct acquire to win the lease first: {b:?}")
11019            }
11020        };
11021
11022        let mut state = RunState::new(
11023            repo.clone(),
11024            "main".to_owned(),
11025            head.clone(),
11026            "task".to_owned(),
11027            config,
11028        );
11029        state.candidates = vec![Candidate {
11030            index: 0,
11031            label: 'A',
11032            agent: "alpha".to_owned(),
11033            branch: "does-not-exist".to_owned(),
11034            worktree: repo.clone(),
11035            summary: String::new(),
11036            stat: String::new(),
11037            files: 0,
11038            commits: 0,
11039            empty: false,
11040            failed: None,
11041            verified_noop: None,
11042            duration_ms: 0,
11043            folded: false,
11044        }];
11045        state.tally = Some(Tally {
11046            first_choice: BTreeMap::from([('A', 1)]),
11047            borda: BTreeMap::new(),
11048            winner: 'A',
11049            rankings: 1,
11050            unanimous_initial: true,
11051            deliberated: false,
11052            changed_votes: 0,
11053            unanimous_final: true,
11054            tie_break: None,
11055            judges: 0,
11056            present: 0,
11057            quorum: 0,
11058            met_quorum: true,
11059            uncontested: Some("only candidate A produced a change".to_owned()),
11060        });
11061        // The round budget's last round, deferred: `needs_catchup_run`'s
11062        // other trigger. `stop_reviewing`'s retry machinery must treat this
11063        // exactly like a resource-blocked attempt once it actually runs.
11064        state.reviews = vec![ReviewRound {
11065            round: 1,
11066            head: head.clone(),
11067            verified_head: None,
11068            verified_at: None,
11069            reviews: Vec::new(),
11070            e2e: Vec::new(),
11071            fix: None,
11072            blocking: 1,
11073            answered: 1,
11074            expected: 1,
11075            clean: false,
11076            verify_retried: false,
11077            e2e_deferred: true,
11078            e2e_defer_reason: Some("1 blocking finding(s) already required a fix".to_owned()),
11079            progressed: false,
11080            vote_split: false,
11081            reconsideration: Vec::new(),
11082            verdict: None,
11083        }];
11084
11085        let mut runner = Runner {
11086            state,
11087            roles: ResolvedRoles {
11088                implementers: Vec::new(),
11089                judges: Vec::new(),
11090                reviewers: Vec::new(),
11091                fixer: None,
11092                conductor: conductor(),
11093                implementer_roster: Vec::new(),
11094                judge_roster: Vec::new(),
11095                reviewer_roster: Vec::new(),
11096            },
11097            sem: Arc::new(Semaphore::new(1)),
11098            pause: Pause::new(),
11099            interrupt: Pause::new(),
11100        };
11101
11102        let shell = runner.state.config.shell();
11103        runner
11104            .stop_reviewing("round budget spent", &shell, &repo)
11105            .await
11106            .expect("stop_reviewing");
11107
11108        let last = runner.state.reviews.last().expect("round record");
11109        assert_eq!(
11110            last.e2e_status(),
11111            E2eStatus::ResourceBlocked,
11112            "the shared cache is still held; the attempt must read as blocked, not deferred or \
11113             failed: {last:?}"
11114        );
11115        assert_eq!(
11116            last.verified_head.as_deref(),
11117            Some(head.as_str()),
11118            "which commit this attempt targeted is known even though nothing finished checking \
11119             it"
11120        );
11121        let first_attempt_at = last
11122            .verified_at
11123            .expect("when this attempt ran is known too");
11124        assert_ne!(
11125            runner.state.status,
11126            RunStatus::Blocked,
11127            "contention is evidence about the machine, not the patch — it must not settle the \
11128             run as blocked: {:?}",
11129            runner.state.status
11130        );
11131        assert!(
11132            !runner
11133                .state
11134                .events
11135                .iter()
11136                .any(|e| e.node == "review" && e.message.contains("e2e failed")),
11137            "a resource-blocked attempt must never be logged as a failed e2e: {:?}",
11138            runner.state.events
11139        );
11140
11141        // The cache is still held: a later reentry must retry the same
11142        // round's verification again — not leave it looking exactly as
11143        // untouched as the first blocked attempt, which is indistinguishable
11144        // from never having tried again at all.
11145        runner
11146            .stop_reviewing("round budget spent", &shell, &repo)
11147            .await
11148            .expect("stop_reviewing retry");
11149        assert_eq!(
11150            runner.state.reviews.len(),
11151            1,
11152            "no new round was started: {:?}",
11153            runner.state.reviews
11154        );
11155        let last = runner.state.reviews.last().expect("round record");
11156        assert_eq!(last.e2e_status(), E2eStatus::ResourceBlocked, "{last:?}");
11157        assert!(
11158            last.verified_at.expect("still known") > first_attempt_at,
11159            "a second reentry must be a fresh attempt, not a stale copy of the first"
11160        );
11161        assert_ne!(runner.state.status, RunStatus::Blocked);
11162
11163        held.release();
11164    }
11165
11166    /// A resumed run — a fresh `Runner`, `self.state.reviews` already
11167    /// holding the round `stop_reviewing` left `ResourceBlocked` from a
11168    /// prior process — must not sit at `Reviewing` forever: `review_loop`'s
11169    /// own top-of-function fast path (`review_conclusion`) correctly reads
11170    /// this shape as `None` rather than guessing `Blocked`, and the loop's
11171    /// own `for` range is empty once the round budget is spent, so
11172    /// `review_loop` must retry the check itself rather than silently doing
11173    /// nothing. Reaches the exact same retry `stop_reviewing_retries_a_*`
11174    /// above exercises directly, but through `review_loop`'s own entry point
11175    /// this time, proving the wiring between the two rather than just the
11176    /// retry logic in isolation.
11177    #[tokio::test]
11178    async fn a_resumed_review_loop_retries_a_last_round_left_resource_blocked() {
11179        crate::run::pin_test_home();
11180        let home = crate::run::home();
11181
11182        let tmp = tempfile::tempdir().expect("tempdir");
11183        let repo = tmp.path().join("repo");
11184        std::fs::create_dir_all(&repo).unwrap();
11185        init_repo(&repo);
11186        let head = crate::git::rev_parse(&repo, "HEAD")
11187            .await
11188            .expect("rev-parse");
11189        let cache_dir = tmp.path().join("target");
11190
11191        let mut config = Config::default();
11192        config.verify.e2e = vec![format!(
11193            "CARGO_TARGET_DIR='{}' test -f README.md",
11194            cache_dir.display()
11195        )];
11196        config.graph.review_rounds = 1;
11197        config.graph.timeout_verify = Some(2);
11198
11199        let other = crate::cache::Owner::here("other-run", "e2e", "e2e", &repo, "deadbeef");
11200        let held = match crate::cache::try_acquire(&home, &cache_dir, &other)
11201            .expect("no io error acquiring directly")
11202        {
11203            crate::cache::AcquireOutcome::Acquired(g) => g,
11204            crate::cache::AcquireOutcome::Busy(b) => {
11205                panic!("expected the direct acquire to win the lease first: {b:?}")
11206            }
11207        };
11208
11209        let mut state = RunState::new(
11210            repo.clone(),
11211            "main".to_owned(),
11212            head.clone(),
11213            "task".to_owned(),
11214            config,
11215        );
11216        state.candidates = vec![Candidate {
11217            index: 0,
11218            label: 'A',
11219            agent: "alpha".to_owned(),
11220            branch: "does-not-exist".to_owned(),
11221            worktree: repo.clone(),
11222            summary: String::new(),
11223            stat: String::new(),
11224            files: 0,
11225            commits: 0,
11226            empty: false,
11227            failed: None,
11228            verified_noop: None,
11229            duration_ms: 0,
11230            folded: false,
11231        }];
11232        state.tally = Some(Tally {
11233            first_choice: BTreeMap::from([('A', 1)]),
11234            borda: BTreeMap::new(),
11235            winner: 'A',
11236            rankings: 1,
11237            unanimous_initial: true,
11238            deliberated: false,
11239            changed_votes: 0,
11240            unanimous_final: true,
11241            tie_break: None,
11242            judges: 0,
11243            present: 0,
11244            quorum: 0,
11245            met_quorum: true,
11246            uncontested: Some("only candidate A produced a change".to_owned()),
11247        });
11248        // The exact shape a prior process's `stop_reviewing` would have left
11249        // on disk: the round budget's last round, a real attempt already
11250        // made and already resource-blocked.
11251        state.reviews = vec![ReviewRound {
11252            round: 1,
11253            head: head.clone(),
11254            verified_head: Some(head.clone()),
11255            verified_at: Some(jiff::Timestamp::now()),
11256            reviews: Vec::new(),
11257            e2e: vec![CommandOutcome {
11258                command: format!(
11259                    "CARGO_TARGET_DIR='{}' test -f README.md",
11260                    cache_dir.display()
11261                ),
11262                code: None,
11263                output_tail: "waiting for the shared build cache".to_owned(),
11264                duration_ms: 0,
11265                resource_blocked: true,
11266            }],
11267            fix: None,
11268            blocking: 1,
11269            answered: 1,
11270            expected: 1,
11271            clean: false,
11272            verify_retried: false,
11273            e2e_deferred: false,
11274            e2e_defer_reason: None,
11275            progressed: false,
11276            vote_split: false,
11277            reconsideration: Vec::new(),
11278            verdict: None,
11279        }];
11280
11281        let first_attempt_at = state.reviews[0].verified_at.expect("set above");
11282        let mut runner = Runner {
11283            state,
11284            roles: ResolvedRoles {
11285                implementers: Vec::new(),
11286                judges: Vec::new(),
11287                reviewers: Vec::new(),
11288                fixer: None,
11289                conductor: conductor(),
11290                implementer_roster: Vec::new(),
11291                judge_roster: Vec::new(),
11292                reviewer_roster: Vec::new(),
11293            },
11294            sem: Arc::new(Semaphore::new(1)),
11295            pause: Pause::new(),
11296            interrupt: Pause::new(),
11297        };
11298
11299        // The lease is still held throughout, so this reentry's own retry is
11300        // also contended — proving `review_loop` actually tried again (not
11301        // that it happened to succeed) is what the timestamp comparison
11302        // below is for.
11303        runner.review_loop().await.expect("review_loop");
11304
11305        assert_eq!(
11306            runner.state.reviews.len(),
11307            1,
11308            "no new round was started on top of the unresolved one: {:?}",
11309            runner.state.reviews
11310        );
11311        let last = &runner.state.reviews[0];
11312        assert_eq!(
11313            last.e2e_status(),
11314            E2eStatus::ResourceBlocked,
11315            "still contended: {last:?}"
11316        );
11317        assert!(
11318            last.verified_at.expect("still known") > first_attempt_at,
11319            "review_loop must have actually retried the check, not left it exactly as found"
11320        );
11321        assert_ne!(
11322            runner.state.status,
11323            RunStatus::Blocked,
11324            "a resumed run must not read leftover contention as a verdict on the patch: {:?}",
11325            runner.state.status
11326        );
11327
11328        held.release();
11329    }
11330
11331    #[tokio::test]
11332    async fn a_run_resumed_mid_landing_reenters_land_instead_of_opening_a_second_pull_request() {
11333        crate::run::pin_test_home();
11334        let tmp = tempfile::tempdir().expect("tempdir");
11335        let repo = tmp.path().join("repo");
11336        std::fs::create_dir_all(&repo).unwrap();
11337        init_repo(&repo);
11338
11339        let mut config = Config::default();
11340        config.merge.mode = MergeMode::Pr;
11341        config.graph.land = true;
11342        config.graph.land_approval = false;
11343
11344        let mut state = RunState::new(
11345            repo.clone(),
11346            "main".to_owned(),
11347            "deadbeef".to_owned(),
11348            "task".to_owned(),
11349            config,
11350        );
11351        state.candidates = vec![Candidate {
11352            index: 0,
11353            label: 'A',
11354            agent: "alpha".to_owned(),
11355            branch: "does-not-exist".to_owned(),
11356            worktree: repo.clone(),
11357            summary: String::new(),
11358            stat: String::new(),
11359            files: 0,
11360            commits: 0,
11361            empty: false,
11362            failed: None,
11363            verified_noop: None,
11364            duration_ms: 0,
11365            folded: false,
11366        }];
11367        state.tally = Some(Tally {
11368            first_choice: BTreeMap::from([('A', 1)]),
11369            borda: BTreeMap::new(),
11370            winner: 'A',
11371            rankings: 1,
11372            unanimous_initial: true,
11373            deliberated: false,
11374            changed_votes: 0,
11375            unanimous_final: true,
11376            tie_break: None,
11377            judges: 0,
11378            present: 0,
11379            quorum: 0,
11380            met_quorum: true,
11381            uncontested: Some("only candidate A produced a change".to_owned()),
11382        });
11383        state.reviews = vec![ReviewRound {
11384            round: 1,
11385            head: "deadbeef".to_owned(),
11386            verified_head: None,
11387            verified_at: None,
11388            reviews: Vec::new(),
11389            e2e: Vec::new(),
11390            fix: None,
11391            blocking: 0,
11392            answered: 0,
11393            expected: 0,
11394            clean: true,
11395            verify_retried: false,
11396            e2e_deferred: false,
11397            e2e_defer_reason: None,
11398            progressed: false,
11399            vote_split: false,
11400            reconsideration: Vec::new(),
11401            verdict: None,
11402        }];
11403        state.gate = vec![CommandOutcome {
11404            command: "test".to_owned(),
11405            code: Some(0),
11406            output_tail: String::new(),
11407            duration_ms: 0,
11408            resource_blocked: false,
11409        }];
11410        state.gate_ran = true;
11411        // A first pass through `merge` already pushed and opened this pull
11412        // request; `status` is `Landing` because a previous call into `land`
11413        // parked or was interrupted before it reached a terminal outcome.
11414        state.status = RunStatus::Landing;
11415        state.merge = Some(MergeOutcome {
11416            mode: MergeMode::Pr,
11417            ok: true,
11418            detail: "https://example.invalid/x/y/pull/1".to_owned(),
11419            empty: false,
11420        });
11421
11422        // The Landing-resume shortcut calls `run_land` directly rather than
11423        // through `merge`, which is exactly the call site that used to skip
11424        // `settle_questions` - see the fixture below.
11425        ask_test_home();
11426        let store = ask::Questions::open();
11427        let q = ask_open_question(&store, &state.id);
11428
11429        let mut runner = Runner {
11430            state,
11431            roles: ResolvedRoles {
11432                implementers: Vec::new(),
11433                judges: Vec::new(),
11434                reviewers: Vec::new(),
11435                fixer: None,
11436                conductor: conductor(),
11437                implementer_roster: Vec::new(),
11438                judge_roster: Vec::new(),
11439                reviewer_roster: Vec::new(),
11440            },
11441            sem: Arc::new(Semaphore::new(1)),
11442            pause: Pause::new(),
11443            interrupt: Pause::new(),
11444        };
11445
11446        // `execute`, not `merge` directly: the Landing-resume shortcut lives
11447        // at the top of `execute`, not inside `merge` (see `execute`'s doc)
11448        // exactly because `review_loop` would otherwise clobber the marker
11449        // first.
11450        runner.execute().await.expect("execute");
11451
11452        assert_eq!(
11453            runner.state.merge.as_ref().map(|m| m.detail.as_str()),
11454            Some("https://example.invalid/x/y/pull/1"),
11455            "reentry must not push again or open a second pull request over the \
11456             one `land` is already watching"
11457        );
11458        assert_ne!(
11459            runner.state.status,
11460            RunStatus::Landing,
11461            "land could not actually reach the fake pull request, so it must \
11462             have given up rather than left the run silently parked forever"
11463        );
11464        // `land` could not reach the fake pull request, so it gave up into
11465        // `Blocked` - still resumable, so the question must not have been
11466        // swept just because this branch now also calls `settle_questions`.
11467        assert_eq!(runner.state.status, RunStatus::Blocked);
11468        assert!(
11469            store.get(&q.id).unwrap().status.open(),
11470            "Blocked is still alive; settle_questions must have been a no-op here"
11471        );
11472    }
11473
11474    fn state_with_round(round: ReviewRound) -> RunState {
11475        let mut s = RunState::new(
11476            PathBuf::from("/repo"),
11477            "main".to_owned(),
11478            "abc1234".to_owned(),
11479            "add retries".to_owned(),
11480            Config::default(),
11481        );
11482        s.reviews = vec![round];
11483        s
11484    }
11485
11486    fn finding(id: &str, severity: Severity, title: &str) -> crate::verdict::Finding {
11487        crate::verdict::Finding {
11488            id: id.to_owned(),
11489            severity,
11490            file: None,
11491            line: None,
11492            title: title.to_owned(),
11493            detail: String::new(),
11494        }
11495    }
11496
11497    #[test]
11498    fn pr_body_names_open_findings_and_declined_ones() {
11499        let round = ReviewRound {
11500            round: 2,
11501            head: "deadbee".to_owned(),
11502            verified_head: None,
11503            verified_at: None,
11504            reviews: vec![ReviewRecord {
11505                attempts: 0,
11506                reviewer: 1,
11507                agent: "alpha".to_owned(),
11508                summary: String::new(),
11509                findings: vec![finding("R2-1-1", Severity::Minor, "unused import")],
11510                vote: None,
11511                failed: None,
11512                duration_ms: 0,
11513            }],
11514            e2e: vec![CommandOutcome {
11515                command: "cargo test".to_owned(),
11516                code: Some(0),
11517                output_tail: String::new(),
11518                duration_ms: 0,
11519                resource_blocked: false,
11520            }],
11521            verify_retried: false,
11522            e2e_deferred: false,
11523            e2e_defer_reason: None,
11524            fix: Some(FixRecord {
11525                agent: "alpha".to_owned(),
11526                addressed: Vec::new(),
11527                rejected: vec![crate::verdict::Rejection {
11528                    id: "R1-1-1".to_owned(),
11529                    why: "not reachable from any caller".to_owned(),
11530                }],
11531                notes: String::new(),
11532                committed: true,
11533                failed: None,
11534                duration_ms: 0,
11535                continuation: None,
11536            }),
11537            blocking: 0,
11538            answered: 1,
11539            expected: 1,
11540            clean: false,
11541            progressed: true,
11542            vote_split: false,
11543            reconsideration: Vec::new(),
11544            verdict: None,
11545        };
11546        let state = state_with_round(round);
11547        let body = pr_message(&state, 'A').body;
11548
11549        assert!(body.contains("add retries"), "the task must still be there");
11550        assert!(body.contains("R2-1-1"), "{body}");
11551        assert!(body.contains("unused import"), "{body}");
11552        assert!(body.contains("R1-1-1"), "the declined finding: {body}");
11553        assert!(
11554            body.contains("not reachable from any caller"),
11555            "the reason it was declined: {body}"
11556        );
11557    }
11558
11559    #[test]
11560    fn pr_body_says_nothing_extra_when_the_round_was_clean() {
11561        let round = ReviewRound {
11562            round: 1,
11563            head: "deadbee".to_owned(),
11564            verified_head: None,
11565            verified_at: None,
11566            reviews: vec![ReviewRecord {
11567                attempts: 0,
11568                reviewer: 1,
11569                agent: "alpha".to_owned(),
11570                summary: String::new(),
11571                findings: Vec::new(),
11572                vote: None,
11573                failed: None,
11574                duration_ms: 0,
11575            }],
11576            e2e: Vec::new(),
11577            verify_retried: false,
11578            e2e_deferred: false,
11579            e2e_defer_reason: None,
11580            fix: None,
11581            blocking: 0,
11582            answered: 1,
11583            expected: 1,
11584            clean: true,
11585            progressed: false,
11586            vote_split: false,
11587            reconsideration: Vec::new(),
11588            verdict: None,
11589        };
11590        let state = state_with_round(round);
11591        let body = pr_message(&state, 'A').body;
11592        assert!(!body.contains("Open review findings"), "{body}");
11593        assert!(!body.contains("Declined"), "{body}");
11594    }
11595
11596    fn state_with_summary(instruction: &str, summary: &str) -> RunState {
11597        let mut state = RunState::new(
11598            PathBuf::from("/repo"),
11599            "main".to_owned(),
11600            "abc1234".to_owned(),
11601            instruction.to_owned(),
11602            Config::default(),
11603        );
11604        state.candidates.push(Candidate {
11605            index: 0,
11606            label: 'A',
11607            agent: "alpha".to_owned(),
11608            branch: "magi/x/A".to_owned(),
11609            worktree: PathBuf::from("/wt"),
11610            summary: summary.to_owned(),
11611            stat: String::new(),
11612            files: 1,
11613            commits: 1,
11614            empty: false,
11615            failed: None,
11616            verified_noop: None,
11617            folded: false,
11618            duration_ms: 0,
11619        });
11620        state
11621    }
11622
11623    fn review_state(subjects: &[&str]) -> RunState {
11624        let mut state = state_with_summary(
11625            "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",
11626            "",
11627        );
11628        state.candidates[0].agent = EXISTING_BRANCH.to_owned();
11629        state.reviewed_commits = Some(subjects.iter().map(|s| (*s).to_owned()).collect());
11630        state
11631    }
11632
11633    /// A branch rebased onto a main that moved past the recorded
11634    /// `base_commit` is titled from its own first commit, never main's.
11635    #[tokio::test]
11636    async fn a_rebased_review_branch_is_titled_from_its_own_commits() {
11637        ask_test_home();
11638        let tmp = tempfile::tempdir().unwrap();
11639        let repo = tmp.path().join("repo");
11640        std::fs::create_dir_all(&repo).unwrap();
11641        init_repo(&repo);
11642        let origin = tmp.path().join("origin.git");
11643        let g = |dir: &Path, args: &[&str]| {
11644            let out = std::process::Command::new("git")
11645                .args(args)
11646                .current_dir(dir)
11647                .quiet()
11648                .output()
11649                .expect("spawn git");
11650            assert!(
11651                out.status.success(),
11652                "git {args:?}: {}",
11653                String::from_utf8_lossy(&out.stderr)
11654            );
11655        };
11656        g(
11657            tmp.path(),
11658            &[
11659                "clone",
11660                "--bare",
11661                "-q",
11662                repo.to_str().unwrap(),
11663                origin.to_str().unwrap(),
11664            ],
11665        );
11666        g(
11667            &repo,
11668            &["remote", "add", "origin", origin.to_str().unwrap()],
11669        );
11670        let c1 = git::rev_parse(&repo, "main").await.unwrap();
11671
11672        // Main moves on; the branch is built on top of the new main.
11673        std::fs::write(repo.join("dep.txt"), "bump\n").unwrap();
11674        g(&repo, &["add", "-A"]);
11675        g(
11676            &repo,
11677            &["commit", "-q", "-m", "chore(deps): update a crate"],
11678        );
11679        g(&repo, &["push", "-q", "origin", "main"]);
11680        g(&repo, &["checkout", "-q", "-b", "feat/own"]);
11681        std::fs::write(repo.join("own.txt"), "own\n").unwrap();
11682        g(&repo, &["add", "-A"]);
11683        g(
11684            &repo,
11685            &["commit", "-q", "-m", "fix(daemon): apply a chosen action"],
11686        );
11687        g(&repo, &["checkout", "-q", "main"]);
11688
11689        let start = review_base(&repo, "origin", "main", &c1, "feat/own").await;
11690        assert_eq!(start, git::rev_parse(&repo, "main").await.unwrap());
11691        // Without a readable tracking ref the recorded base's merge base is used.
11692        let fallback = review_base(&repo, "nowhere", "main", &c1, "feat/own").await;
11693        assert_eq!(fallback, c1);
11694
11695        let mut state = review_state(&[
11696            "chore(deps): update a crate",
11697            "fix(daemon): apply a chosen action",
11698        ]);
11699        state.repo = repo.clone();
11700        state.base_branch = "main".to_owned();
11701        state.base_commit = c1;
11702        refresh_reviewed_commits(&mut state, "feat/own").await;
11703        assert_eq!(
11704            state.reviewed_commits,
11705            Some(vec!["fix(daemon): apply a chosen action".to_owned()])
11706        );
11707        assert_eq!(
11708            review_title(&state).as_deref(),
11709            Some("fix(daemon): apply a chosen action")
11710        );
11711        assert_eq!(
11712            leaked_subjects(&state, "feat/own").await,
11713            Some(vec!["chore(deps): update a crate".to_owned()])
11714        );
11715        // A stale tracking ref is still used when the fetch fails, but the
11716        // leak list is withheld.
11717        state.config.merge.remote = "nowhere".to_owned();
11718        assert_eq!(leaked_subjects(&state, "feat/own").await, None);
11719    }
11720
11721    #[test]
11722    fn pr_message_review_single_commit_uses_its_subject() {
11723        let state = review_state(&["feat(nats): per-role user"]);
11724        let m = pr_message(&state, 'A');
11725        assert_eq!(m.title, "feat(nats): per-role user");
11726        assert!(!m.body.contains("Review the work already"), "{}", m.body);
11727        assert!(m.body.contains("## Commits under review"), "{}", m.body);
11728    }
11729
11730    #[test]
11731    fn pr_message_review_multi_commit_takes_the_oldest() {
11732        let state = review_state(&["feat: the change", "fix: typo", "fix: again"]);
11733        let m = pr_message(&state, 'A');
11734        assert_eq!(m.title, "feat: the change");
11735        for s in ["feat: the change", "fix: typo", "fix: again"] {
11736            assert!(m.body.contains(&format!("- {s}\n")), "{}", m.body);
11737        }
11738    }
11739
11740    #[test]
11741    fn pr_message_review_without_a_usable_first_subject_is_neutral() {
11742        for first in ["日本語の件名", "", "magi: candidate A (uncommitted work)"] {
11743            let mut state = review_state(&[first, "fix: later fixup"]);
11744            state.candidates[0].branch = "機能/ログイン".to_owned();
11745            let m = pr_message(&state, 'A');
11746            assert!(
11747                m.title.starts_with("chore: land candidate A of run"),
11748                "{}",
11749                m.title
11750            );
11751        }
11752    }
11753
11754    fn facts(commits: &[(&str, &str)], stat: &str) -> BranchFacts {
11755        BranchFacts {
11756            commits: commits
11757                .iter()
11758                .map(|(s, b)| ((*s).to_owned(), (*b).to_owned()))
11759                .collect(),
11760            stat: stat.to_owned(),
11761        }
11762    }
11763
11764    fn round_with_notes(round: usize, notes: Option<&str>) -> ReviewRound {
11765        let mut r = review_round(true, 0, 1, 1, true, true);
11766        r.round = round;
11767        r.fix = notes.map(|n| FixRecord {
11768            agent: "fixer".to_owned(),
11769            addressed: Vec::new(),
11770            rejected: Vec::new(),
11771            notes: n.to_owned(),
11772            committed: true,
11773            failed: None,
11774            duration_ms: 0,
11775            continuation: None,
11776        });
11777        r
11778    }
11779
11780    #[test]
11781    fn pr_message_review_with_branch_facts_uses_commits_and_stat() {
11782        let state = review_state(&["ignored"]);
11783        let f = facts(
11784            &[
11785                (
11786                    "fix(login): resolve PATH on macOS",
11787                    "Login shells skip rc files.",
11788                ),
11789                ("fix: address review", ""),
11790            ],
11791            " src/a.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)",
11792        );
11793        let m = pr_message_with(&state, 'A', Some(&f));
11794        assert_eq!(m.title, "fix(login): resolve PATH on macOS");
11795        assert!(
11796            m.body
11797                .contains("- fix(login): resolve PATH on macOS\n  Login shells skip rc files.\n"),
11798            "{}",
11799            m.body
11800        );
11801        assert!(m.body.contains("- fix: address review\n"), "{}", m.body);
11802        assert!(m.body.contains("## Diff stat"), "{}", m.body);
11803        assert!(m.body.contains("src/a.rs | 2 +-"), "{}", m.body);
11804        for banned in [
11805            "Review the work already",
11806            "no task statement",
11807            "Original task",
11808        ] {
11809            assert!(!m.body.contains(banned), "{banned}: {}", m.body);
11810        }
11811        assert!(m.body.ends_with("magi:candidate-a\n"), "{}", m.body);
11812    }
11813
11814    #[test]
11815    fn pr_message_review_truncates_a_huge_first_commit_body() {
11816        let state = review_state(&["ignored"]);
11817        let f = facts(
11818            &[("feat: big", &"x".repeat(70_000)), ("fix: later", "")],
11819            "s",
11820        );
11821        let m = pr_message_with(&state, 'A', Some(&f));
11822        assert!(m.body.len() < 30_000, "{}", m.body.len());
11823        assert!(m.body.contains("(truncated)"), "{}", m.body.len());
11824        assert!(
11825            m.body.contains("- ... 1 more commit(s)"),
11826            "{}",
11827            m.body.len()
11828        );
11829        assert!(m.body.ends_with("magi:candidate-a\n"));
11830    }
11831
11832    #[test]
11833    fn pr_message_review_without_facts_falls_back_to_recorded_subjects() {
11834        let m = pr_message_with(&review_state(&["feat: x", "fix: y"]), 'A', None);
11835        assert_eq!(m.title, "feat: x");
11836        assert!(m.body.contains("- fix: y\n"), "{}", m.body);
11837        assert!(!m.body.contains("Diff stat"), "{}", m.body);
11838        assert!(!m.body.contains("no task statement"), "{}", m.body);
11839    }
11840
11841    #[test]
11842    fn pr_message_review_titles_from_the_branch_name_when_subjects_are_unusable() {
11843        let mut state = review_state(&["日本語の件名"]);
11844        state.candidates[0].branch = "fix/macos-login-path".to_owned();
11845        assert_eq!(pr_message(&state, 'A').title, "fix/macos-login-path");
11846        state.candidates[0].branch = "機能/ログイン".to_owned();
11847        assert!(
11848            pr_message(&state, 'A')
11849                .title
11850                .starts_with("chore: land candidate A")
11851        );
11852    }
11853
11854    #[test]
11855    fn pr_message_review_fixes_survive_a_clean_final_round() {
11856        let mut state = review_state(&["feat: x"]);
11857        state.reviews = vec![
11858            round_with_notes(1, Some("handled the PATH case")),
11859            round_with_notes(2, None),
11860        ];
11861        let body = pr_message(&state, 'A').body;
11862        assert!(
11863            body.contains("## Review fixes\n\nhandled the PATH case\n"),
11864            "{body}"
11865        );
11866        assert!(!body.contains("### Round"), "{body}");
11867
11868        state.reviews = vec![
11869            round_with_notes(1, Some("first fix")),
11870            round_with_notes(2, Some("")),
11871            round_with_notes(3, Some("second fix")),
11872            round_with_notes(4, None),
11873        ];
11874        let body = pr_message(&state, 'A').body;
11875        assert!(body.contains("### Round 1\n\nfirst fix"), "{body}");
11876        assert!(body.contains("### Round 3\n\nsecond fix"), "{body}");
11877        assert!(!body.contains("### Round 2"), "{body}");
11878    }
11879
11880    #[test]
11881    fn pr_message_implementation_run_keeps_its_shape_and_marker() {
11882        let state = state_with_summary(
11883            "add retries to the client",
11884            "TITLE: feat: retries\n\nDid it.",
11885        );
11886        let m = pr_message_with(&state, 'A', Some(&facts(&[("x", "")], "s")));
11887        assert_eq!(m.title, "feat: retries");
11888        assert!(
11889            m.body.contains("<summary>Original task</summary>"),
11890            "{}",
11891            m.body
11892        );
11893        assert!(!m.body.contains("Commits under review"), "{}", m.body);
11894        assert!(
11895            m.body
11896                .ends_with(&format!("magi:run/{} magi:candidate-a\n", state.id)),
11897            "{}",
11898            m.body
11899        );
11900    }
11901
11902    #[test]
11903    fn pr_message_review_bounds_a_long_english_subject() {
11904        let long = format!("feat: {}", "word ".repeat(100));
11905        let m = pr_message(&review_state(&[&long]), 'A');
11906        assert!(m.title.starts_with("feat: word"), "{}", m.title);
11907        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
11908    }
11909
11910    #[test]
11911    fn pr_message_implementation_run_is_unchanged_by_review_support() {
11912        let state = state_with_summary("add retries\n\ndetails", "- did some things");
11913        let m = pr_message(&state, 'A');
11914        assert_eq!(m.title, "add retries");
11915        assert!(m.body.contains("<summary>Original task</summary>"));
11916        assert!(!m.body.contains("Commits under review"));
11917        assert_eq!(landing_subject_source(&state), state.instruction);
11918    }
11919
11920    #[test]
11921    fn review_run_squash_subject_is_the_change_not_the_prompt() {
11922        let state = review_state(&["feat: the change", "fix: typo"]);
11923        let source = landing_subject_source(&state);
11924        assert_eq!(land::merge_subject("", &source), "feat: the change");
11925        assert_eq!(
11926            land::merge_subject("magi: candidate A (uncommitted work)", &source),
11927            "feat: the change"
11928        );
11929        // An operator's rename still wins.
11930        assert_eq!(
11931            land::merge_subject("feat: renamed by hand", &source),
11932            "feat: renamed by hand"
11933        );
11934        let blank = review_state(&["日本語"]);
11935        assert!(
11936            land::merge_subject("", &landing_subject_source(&blank)).starts_with("chore: land")
11937        );
11938    }
11939
11940    #[test]
11941    fn review_run_drops_a_prompt_shaped_pr_title_at_landing() {
11942        let state = review_state(&["feat: the change"]);
11943        let source = landing_subject_source(&state);
11944        let old = "Review the work already on branch `magi/x/A`. There is no task statement";
11945        assert_eq!(
11946            land::merge_subject(landing_title(&state, old), &source),
11947            "feat: the change"
11948        );
11949        assert_eq!(landing_title(&state, "feat: renamed"), "feat: renamed");
11950        let task = state_with_summary("add retries", "");
11951        assert_eq!(landing_title(&task, old), old);
11952    }
11953
11954    #[test]
11955    fn pr_message_describes_the_change_not_the_task() {
11956        let state = state_with_summary(
11957            "今回やってほしいこと: results projector を直す",
11958            "TITLE: fix(web): batch the runs list reads\n- reads run.json once\n- risk: none",
11959        );
11960        let m = pr_message(&state, 'A');
11961        assert_eq!(m.title, "fix(web): batch the runs list reads");
11962        assert!(
11963            m.body.starts_with("## Summary\n\n- reads run.json once"),
11964            "{}",
11965            m.body
11966        );
11967        assert!(!m.body.contains("TITLE:"), "{}", m.body);
11968        let task_at = m.body.find("今回やってほしいこと").unwrap();
11969        let details_at = m.body.find("<details>").unwrap();
11970        assert!(
11971            details_at < task_at,
11972            "the task lives inside <details>: {}",
11973            m.body
11974        );
11975        assert!(m.body.contains(&format!("magi:run/{}", state.id)));
11976        assert!(m.body.contains("magi:candidate-a"));
11977    }
11978
11979    #[test]
11980    fn pr_message_falls_back_to_the_task_without_a_title_line() {
11981        let state = state_with_summary("\n\nadd retries\n\ndetails", "- did some things");
11982        let m = pr_message(&state, 'A');
11983        assert_eq!(m.title, "add retries");
11984        assert!(
11985            m.body.contains("## Summary\n\n- did some things"),
11986            "{}",
11987            m.body
11988        );
11989
11990        let none = RunState::new(
11991            PathBuf::from("/repo"),
11992            "main".to_owned(),
11993            "abc1234".to_owned(),
11994            "add retries".to_owned(),
11995            Config::default(),
11996        );
11997        let m = pr_message(&none, 'A');
11998        assert_eq!(m.title, "add retries");
11999        assert!(!m.body.contains("## Summary"), "{}", m.body);
12000    }
12001
12002    #[test]
12003    fn pr_message_refuses_the_candidate_commit_subject() {
12004        for bad in [
12005            "TITLE: magi: candidate A (uncommitted work)",
12006            "TITLE: chore: stuff (uncommitted work)",
12007            "TITLE:   ",
12008        ] {
12009            let state = state_with_summary("add retries", bad);
12010            assert_eq!(pr_message(&state, 'A').title, "add retries", "{bad}");
12011        }
12012    }
12013
12014    #[test]
12015    fn pr_message_bounds_a_very_long_task_and_title() {
12016        let long = format!("fix the thing 🎉 {}", "x".repeat(5000));
12017        let state = state_with_summary(&long, "- nothing");
12018        let m = pr_message(&state, 'A');
12019        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12020        assert!(!m.title.contains('\n'));
12021
12022        let state = state_with_summary("task", &format!("TITLE: feat: {}", "y".repeat(5000)));
12023        let m = pr_message(&state, 'A');
12024        assert!(m.title.starts_with("feat: "));
12025        assert!(m.title.chars().count() <= PR_TITLE_MAX, "{}", m.title);
12026        assert_eq!(m.commit_message().lines().next(), Some(m.title.as_str()));
12027    }
12028
12029    fn long_title_of(instruction: &str) -> String {
12030        pr_message(&state_with_summary(instruction, "- nothing"), 'A').title
12031    }
12032
12033    #[test]
12034    fn pr_message_cuts_a_long_english_line_at_its_first_sentence() {
12035        let first = "Make the landing path keep a readable title for long tasks";
12036        let line = format!(
12037            "{first}. {}",
12038            "And then keep going with more words ".repeat(20)
12039        );
12040        let t = long_title_of(&line);
12041        assert_eq!(t, first);
12042        assert!(!t.starts_with("chore: land"));
12043    }
12044
12045    #[test]
12046    fn pr_message_cuts_a_sentenceless_long_line_at_a_word() {
12047        let line = "word ".repeat(200);
12048        let t = long_title_of(&line);
12049        assert!(t.ends_with("word..."), "{t}");
12050        assert!(t.is_ascii() && t.chars().count() <= PR_TITLE_MAX, "{t}");
12051    }
12052
12053    #[test]
12054    fn pr_message_long_non_english_or_letterless_line_is_neutral() {
12055        for line in ["日本語のタスク ".repeat(80), "1234 ".repeat(100)] {
12056            assert!(long_title_of(&line).starts_with("chore: land"), "{line}");
12057        }
12058    }
12059
12060    #[test]
12061    fn pr_message_title_limit_is_exact() {
12062        let at = "a".repeat(PR_TITLE_MAX);
12063        assert_eq!(long_title_of(&at), at);
12064        let over = long_title_of(&"a".repeat(PR_TITLE_MAX + 1));
12065        assert!(over.ends_with("..."), "{over}");
12066        assert_eq!(over.chars().count(), PR_TITLE_MAX);
12067    }
12068
12069    #[test]
12070    fn pr_message_judges_the_kept_text_not_what_follows_the_cut() {
12071        let line = format!("{} \u{2014} tail", "alpha beta ".repeat(40));
12072        let t = long_title_of(&line);
12073        assert!(t.ends_with("..."), "{t}");
12074        assert!(t.is_ascii(), "{t}");
12075    }
12076
12077    #[test]
12078    fn pr_message_sentence_cut_skips_abbreviations_and_decimals() {
12079        let line = format!(
12080            "Support several shells, e.g. bash and zsh, at version 1.5 or newer when it matters {}",
12081            "plus more filler words ".repeat(20)
12082        );
12083        let t = long_title_of(&line);
12084        assert!(t.contains("e.g. bash") && t.contains("1.5 or newer"), "{t}");
12085    }
12086
12087    #[test]
12088    fn pr_message_long_title_survives_a_blank_first_line_and_the_squash_subject() {
12089        let line = format!("\n\n# {}", "title words ".repeat(40));
12090        let state = state_with_summary(&line, "- nothing");
12091        let m = pr_message(&state, 'A');
12092        assert!(m.title.starts_with("title words"), "{}", m.title);
12093        assert_eq!(
12094            land::merge_subject(&m.title, &landing_subject_source(&state)),
12095            m.title
12096        );
12097        // An operator's rename wins untouched.
12098        assert_eq!(
12099            land::merge_subject("feat: renamed by hand", &landing_subject_source(&state)),
12100            "feat: renamed by hand"
12101        );
12102    }
12103
12104    #[tokio::test]
12105    async fn github_text_rewrite_is_bounded_and_falls_back() {
12106        crate::run::pin_test_home();
12107        for (reply, accepted) in [
12108            (
12109                "TITLE: fix: retries\nAdd retries for failed requests.",
12110                true,
12111            ),
12112            (
12113                "TITLE: fix: retries\n日本語の説明をもう一度書きます。",
12114                false,
12115            ),
12116            ("TITLE: fix: retries\nUse token=secret", false),
12117        ] {
12118            let dir = tempfile::tempdir().unwrap();
12119            let mut runner = runner_at(RunStatus::Gating);
12120            runner.state = state_with_summary(
12121                "add retries",
12122                "TITLE: fix: retries\n日本語の説明を書きます。",
12123            );
12124            runner.state.repo = dir.path().to_owned();
12125            runner.state.candidates[0].worktree = dir.path().to_owned();
12126            let mut author = spec("alpha");
12127            author.command = vec![
12128                "sh".into(),
12129                "-c".into(),
12130                "printf '%s' \"$REWRITE_REPLY\"".into(),
12131            ];
12132            author.env.insert("REWRITE_REPLY".into(), reply.into());
12133            runner.state.config.agents = vec![author];
12134            let winner = runner.state.candidates[0].clone();
12135            let first = runner
12136                .guarded_pr_message(&winner, None, true)
12137                .await
12138                .unwrap();
12139            let message = if accepted {
12140                first.expect("a passing rewrite never asks")
12141            } else {
12142                // Withheld: the run parks on a question and posts nothing yet.
12143                assert!(first.is_none());
12144                assert!(runner.state.parked);
12145                let store = ask::Questions::open();
12146                let asked: Vec<_> = store
12147                    .list()
12148                    .into_iter()
12149                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12150                    .collect();
12151                assert_eq!(asked.len(), 1);
12152                assert!(!asked[0].detail.contains("secret"));
12153                // Silence: the question lapses and the neutral text is used,
12154                // without a second rewrite call or a second question.
12155                store
12156                    .update(&asked[0].id, |q| {
12157                        q.abandon("test");
12158                        Ok(())
12159                    })
12160                    .unwrap();
12161                let again = runner
12162                    .guarded_pr_message(&winner, None, true)
12163                    .await
12164                    .unwrap()
12165                    .expect("an abandoned question falls back");
12166                let n = store
12167                    .list()
12168                    .into_iter()
12169                    .filter(|q| q.run == runner.state.id && q.node == crate::github_text::ASK_NODE)
12170                    .count();
12171                assert_eq!(n, 1, "asked once per rejected text");
12172                again
12173            };
12174            assert!(crate::github_text::check(&message.title, &message.body).is_empty());
12175            assert_eq!(
12176                message.body.contains("Add retries for failed requests."),
12177                accepted
12178            );
12179            assert_eq!(
12180                runner.state.seats["impl-A"].turns, 1,
12181                "only one rewrite invocation"
12182            );
12183            assert!(runner.state.events.iter().any(|e| e.node == "github-text"));
12184            if !accepted {
12185                assert!(message.body.contains(crate::github_text::NEUTRAL_BODY));
12186                assert!(
12187                    message
12188                        .body
12189                        .contains(&format!("magi:run/{}", runner.state.id))
12190                );
12191            }
12192        }
12193    }
12194
12195    #[tokio::test]
12196    async fn withheld_title_takes_the_owner_replacement_and_rechecks_it() {
12197        crate::run::pin_test_home();
12198        let dir = tempfile::tempdir().unwrap();
12199        let mut runner = runner_at(RunStatus::Gating);
12200        runner.state = state_with_summary("add retries", "TITLE: 日本語のタイトル\nAdd retries.");
12201        runner.state.repo = dir.path().to_owned();
12202        runner.state.candidates[0].worktree = dir.path().to_owned();
12203        let winner = runner.state.candidates[0].clone();
12204        assert!(
12205            runner
12206                .guarded_pr_message(&winner, None, true)
12207                .await
12208                .unwrap()
12209                .is_none()
12210        );
12211        let store = ask::Questions::open();
12212        let find = |run: &str| {
12213            store
12214                .list()
12215                .into_iter()
12216                .find(|q| q.run == run && q.node == crate::github_text::ASK_NODE && q.status.open())
12217                .unwrap()
12218        };
12219        let run = runner.state.id.clone();
12220        let q = find(&run);
12221        let fp = runner
12222            .state
12223            .github_text
12224            .as_ref()
12225            .unwrap()
12226            .fingerprint
12227            .clone();
12228        let name = crate::github_text::artifact_name(&fp);
12229        let path = crate::run::artifact_path(&runner.state, &name);
12230        assert!(q.detail.contains(&path.display().to_string()));
12231        assert!(q.detail.contains("title-language: title"));
12232        let saved = std::fs::read_to_string(&path).expect("artifact written");
12233        assert!(saved.contains("日本語のタイトル"));
12234        assert!(q.cwd.is_none());
12235        assert_eq!(q.choices, ["use fallback", "use my text"]);
12236        // A bad replacement is refused and asked about once more.
12237        let say = |id: &str, text: &str, choice: &str| {
12238            store
12239                .update(id, |q| {
12240                    q.thread.push(ask::Turn {
12241                        who: ask::Who::Operator,
12242                        body: text.to_owned(),
12243                        at: jiff::Timestamp::now(),
12244                        note: None,
12245                    });
12246                    q.answer(ask::Answer::Choice(choice.to_owned()))
12247                })
12248                .unwrap();
12249        };
12250        say(&q.id, "まだ日本語", "use my text");
12251        assert!(
12252            runner
12253                .guarded_pr_message(&winner, None, true)
12254                .await
12255                .unwrap()
12256                .is_none()
12257        );
12258        let second = find(&run);
12259        assert_ne!(second.id, q.id);
12260        assert!(second.detail.contains("did not pass"));
12261        assert!(second.detail.contains("まだ日本語"));
12262        let retry_path = crate::run::artifact_path(
12263            &runner.state,
12264            &crate::github_text::artifact_name(&crate::github_text::fingerprint("まだ日本語", "")),
12265        );
12266        assert!(second.detail.contains(&retry_path.display().to_string()));
12267        assert!(
12268            std::fs::read_to_string(&retry_path)
12269                .expect("retry artifact written")
12270                .contains("まだ日本語")
12271        );
12272        say(&second.id, "fix: retry failed requests", "use my text");
12273        let message = runner
12274            .guarded_pr_message(&winner, None, true)
12275            .await
12276            .unwrap()
12277            .expect("a vetted replacement is posted");
12278        assert_eq!(message.title, "fix: retry failed requests");
12279        assert_eq!(
12280            runner
12281                .state
12282                .github_text
12283                .as_ref()
12284                .unwrap()
12285                .chosen_title
12286                .as_deref(),
12287            Some("fix: retry failed requests")
12288        );
12289        assert!(runner.state.github_text.as_ref().unwrap().resolved);
12290        // Resumed again: the saved decision is applied, nothing is re-asked.
12291        let again = runner
12292            .guarded_pr_message(&winner, None, true)
12293            .await
12294            .unwrap()
12295            .unwrap();
12296        assert_eq!(again.title, message.title);
12297        assert_eq!(
12298            store
12299                .list()
12300                .into_iter()
12301                .filter(|q| q.run == run && q.node == crate::github_text::ASK_NODE)
12302                .count(),
12303            2
12304        );
12305    }
12306
12307    #[test]
12308    fn pr_message_magi_text_is_english_and_the_task_is_verbatim() {
12309        // What magi itself writes stays English under any configured language,
12310        // so a future localisation of these headings fails here. (The agents'
12311        // own text is also checked by the posting gate.)
12312        let mut state = state_with_summary(
12313            "add retries",
12314            "TITLE: fix(web): batch reads\n- reads run.json once",
12315        );
12316        state.config.graph.language = "ja".to_owned();
12317        let m = pr_message(&state, 'A');
12318        assert!(crate::github_text::check(&m.title, &m.body).is_empty());
12319        assert!(m.title.is_ascii() && m.body.is_ascii(), "{}", m.body);
12320
12321        // The task is the operator's own text: it goes in untouched, and the
12322        // fallback title (no summary) may be in its language too.
12323        let task = "今回やってほしいこと: results projector を直す";
12324        let mut state = state_with_summary(task, "- no title line");
12325        state.config.graph.language = "ja".to_owned();
12326        let m = pr_message(&state, 'A');
12327        assert_eq!(
12328            m.title,
12329            format!("chore: land candidate A of run {}", state.id)
12330        );
12331        assert!(
12332            m.body.contains(&format!(
12333                "<summary>Original task</summary>\n\n{task}\n\n</details>"
12334            )),
12335            "{}",
12336            m.body
12337        );
12338    }
12339
12340    #[test]
12341    fn pr_message_scrubs_home_paths_and_addresses() {
12342        let state = state_with_summary(
12343            "fix it in /Users/someone/src/x",
12344            "TITLE: fix(x): y\n- edited /home/someone/repo/src/a.rs on 10.1.2.3",
12345        );
12346        let m = pr_message(&state, 'A');
12347        for leak in ["/Users/someone", "/home/someone", "10.1.2.3"] {
12348            assert!(!m.body.contains(leak), "{}", m.body);
12349        }
12350        assert!(m.body.contains("~/repo/src/a.rs"), "{}", m.body);
12351    }
12352
12353    #[test]
12354    fn pr_message_survives_a_task_that_closes_details() {
12355        let state = state_with_summary("a </details> b", "TITLE: fix: x");
12356        let m = pr_message(&state, 'A');
12357        assert_eq!(m.body.matches("</details>").count(), 1, "{}", m.body);
12358    }
12359
12360    #[test]
12361    fn manual_squash_subject_cannot_break_out_of_its_quotes() {
12362        let cmd = manual_merge_command(
12363            MergeStyle::Squash,
12364            Path::new("/repo"),
12365            "b",
12366            "fix: \"quoted\" $(x) `y`\n\nbody",
12367        );
12368        assert!(cmd.ends_with("commit -m \"fix: quoted (x) y\""), "{cmd}");
12369    }
12370
12371    #[test]
12372    fn manual_merge_command_matches_the_configured_style() {
12373        let repo = Path::new("/repo");
12374        let message = "Merge magi run 0832 (candidate A)\n\nadd retries";
12375
12376        let merge = manual_merge_command(MergeStyle::Merge, repo, "magi/0832/A", message);
12377        assert_eq!(merge, "git -C /repo merge --no-ff magi/0832/A");
12378
12379        let squash = manual_merge_command(MergeStyle::Squash, repo, "magi/0832/A", message);
12380        assert_eq!(
12381            squash,
12382            "git -C /repo merge --squash magi/0832/A && git -C /repo commit -m \
12383             \"Merge magi run 0832 (candidate A)\""
12384        );
12385
12386        let rebase = manual_merge_command(MergeStyle::Rebase, repo, "magi/0832/A", message);
12387        assert_eq!(rebase, "git -C /repo merge --ff-only magi/0832/A");
12388    }
12389
12390    #[test]
12391    fn a_nudge_gets_a_quarter_of_the_budget() {
12392        // The judge and implement budgets magi ships with.
12393        assert_eq!(retry_budget(secs(1200), true), secs(300));
12394        assert_eq!(retry_budget(secs(3600), true), secs(900));
12395    }
12396
12397    #[test]
12398    fn a_resent_prompt_keeps_the_whole_budget() {
12399        // The seat kept no context, so the retry is the original job again and
12400        // shortening it would only guarantee a second failure.
12401        assert_eq!(retry_budget(secs(1200), false), secs(1200));
12402        assert_eq!(retry_budget(secs(60), false), secs(60));
12403    }
12404
12405    #[test]
12406    fn the_floor_never_exceeds_the_original_budget() {
12407        // A short configured timeout must not be *raised* by the floor: the
12408        // operator asked for a bound, and a retry may not outlast the attempt
12409        // it is retrying.
12410        assert_eq!(retry_budget(secs(60), true), secs(60));
12411        assert_eq!(retry_budget(secs(480), true), secs(120));
12412        assert_eq!(retry_budget(secs(0), true), secs(0));
12413    }
12414
12415    fn evidence(exit_code: Option<i32>) -> agent::CommandEvidence {
12416        agent::CommandEvidence {
12417            id: "item1".to_owned(),
12418            description: "cargo test".to_owned(),
12419            exit_code,
12420            result_summary: String::new(),
12421            source: "codex".to_owned(),
12422        }
12423    }
12424
12425    #[test]
12426    fn a_reply_with_no_commands_at_all_is_not_unconfirmed() {
12427        // No evidence is not the same fact as unconfirmed evidence: a
12428        // backend with no adapter, or a reply that ran no commands at all,
12429        // must not be misread as carrying a dangling job.
12430        assert!(!has_unconfirmed_command(&[]));
12431    }
12432
12433    #[test]
12434    fn a_command_with_a_real_exit_code_is_confirmed_whatever_its_value() {
12435        // Deliberately not a check on the exit code's *value*: a fixer
12436        // legitimately runs something that fails mid-iteration before it
12437        // succeeds, and that must never by itself reopen a valid report.
12438        assert!(!has_unconfirmed_command(&[evidence(Some(0))]));
12439        assert!(!has_unconfirmed_command(&[evidence(Some(1))]));
12440        assert!(!has_unconfirmed_command(&[
12441            evidence(Some(0)),
12442            evidence(Some(101))
12443        ]));
12444    }
12445
12446    #[test]
12447    fn one_command_with_no_readable_exit_code_is_enough_to_flag_the_reply() {
12448        assert!(has_unconfirmed_command(&[
12449            evidence(Some(0)),
12450            evidence(None)
12451        ]));
12452    }
12453
12454    #[test]
12455    fn a_clean_usable_reply_with_the_marker_is_a_verified_claim() {
12456        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12457        assert_eq!(
12458            verified_noop_claim(true, &[], text).as_deref(),
12459            Some("already fixed by b32cfc4, on main.")
12460        );
12461    }
12462
12463    #[test]
12464    fn an_unusable_reply_never_earns_the_benefit_of_the_doubt() {
12465        // A timeout or a bad exit code reads as the ordinary loss it is,
12466        // whatever the reply's own prose claims.
12467        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12468        assert!(verified_noop_claim(false, &[], text).is_none());
12469    }
12470
12471    #[test]
12472    fn an_unconfirmed_command_disqualifies_the_claim_even_on_a_usable_reply() {
12473        let text = "NO CHANGE NEEDED: already fixed by b32cfc4, on main.";
12474        assert!(verified_noop_claim(true, &[evidence(None)], text).is_none());
12475        // A confirmed command alongside the marker is fine.
12476        assert!(verified_noop_claim(true, &[evidence(Some(0))], text).is_some());
12477    }
12478
12479    #[test]
12480    fn an_ordinary_reply_with_no_marker_is_never_a_claim() {
12481        assert!(verified_noop_claim(true, &[], "- did the thing\n- tested it").is_none());
12482    }
12483
12484    /// Sets `runner.state.candidates` to one candidate per `(empty, verified)`
12485    /// pair, in order, labelled A, B, C, ...
12486    fn set_candidates(runner: &mut Runner, shape: &[(bool, Option<&str>)]) {
12487        runner.state.candidates = shape
12488            .iter()
12489            .enumerate()
12490            .map(|(i, &(empty, verified))| Candidate {
12491                index: i,
12492                label: (b'A' + i as u8) as char,
12493                agent: "sonnet".to_owned(),
12494                branch: format!("magi/x/{}", (b'A' + i as u8) as char),
12495                worktree: PathBuf::from(format!("/wt/{i}")),
12496                summary: String::new(),
12497                stat: String::new(),
12498                files: 0,
12499                commits: 0,
12500                empty,
12501                failed: None,
12502                verified_noop: verified.map(str::to_owned),
12503                duration_ms: 0,
12504                folded: false,
12505            })
12506            .collect();
12507    }
12508
12509    #[test]
12510    fn after_implement_reads_all_candidates_verified_as_a_noop_not_a_failure() {
12511        ask_test_home();
12512        let mut runner = runner_at(RunStatus::Implementing);
12513        set_candidates(
12514            &mut runner,
12515            &[
12516                (true, Some("already on main at b32cfc4")),
12517                (true, Some("same fix, see the existing test")),
12518            ],
12519        );
12520
12521        runner
12522            .after_implement()
12523            .expect("a verified no-op is not an error");
12524
12525        assert_eq!(runner.state.status, RunStatus::VerifiedNoop);
12526    }
12527
12528    #[test]
12529    fn after_implement_does_not_accept_one_candidates_claim_next_to_an_ordinary_loss() {
12530        ask_test_home();
12531        let mut runner = runner_at(RunStatus::Implementing);
12532        // Candidate A declares a verified no-op; candidate B simply wrote
12533        // nothing and said nothing about why. One candidate's claim is not
12534        // the whole run's agreement.
12535        set_candidates(
12536            &mut runner,
12537            &[(true, Some("already on main at b32cfc4")), (true, None)],
12538        );
12539
12540        let err = runner
12541            .after_implement()
12542            .expect_err("an unverified empty candidate must still fail the run");
12543
12544        assert!(
12545            err.to_string().contains("no candidate produced a change"),
12546            "{err}"
12547        );
12548        assert_eq!(runner.state.status, RunStatus::Failed);
12549    }
12550
12551    #[test]
12552    fn after_implement_still_fails_an_ordinary_all_empty_run() {
12553        ask_test_home();
12554        let mut runner = runner_at(RunStatus::Implementing);
12555        set_candidates(&mut runner, &[(true, None), (true, None)]);
12556
12557        let err = runner
12558            .after_implement()
12559            .expect_err("no candidate declared anything; this is an ordinary failure");
12560
12561        assert!(
12562            err.to_string().contains("no candidate produced a change"),
12563            "{err}"
12564        );
12565        assert_eq!(runner.state.status, RunStatus::Failed);
12566    }
12567}